Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
addPromptInputOption,
addSessionNameOption,
addSessionOption,
parseAllowedTools,
parseHistoryLimit,
parseMaxTurns,
parseNonEmptyValue,
parseSessionName,
parseTtlSeconds,
Expand Down Expand Up @@ -145,7 +147,7 @@ function emitJsonResult(format: OutputFormat, payload: unknown): boolean {
return true;
}

export { parseTtlSeconds };
export { parseAllowedTools, parseMaxTurns, parseTtlSeconds };
export { formatPromptSessionBannerLine } from "./cli/output-render.js";

type SessionModule = typeof import("./session.js");
Expand Down Expand Up @@ -333,6 +335,11 @@ async function handleExec(
suppressSdkConsoleErrors: outputPolicy.suppressSdkConsoleErrors,
timeoutMs: globalFlags.timeout,
verbose: globalFlags.verbose,
sessionOptions: {
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
},
});

applyPermissionExitCode(result);
Expand Down Expand Up @@ -608,6 +615,11 @@ async function handleSessionsNew(
authPolicy: globalFlags.authPolicy,
timeoutMs: globalFlags.timeout,
verbose: globalFlags.verbose,
sessionOptions: {
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
},
});

printCreatedSessionBanner(created, agent.agentName, globalFlags.format, globalFlags.jsonStrict);
Expand Down Expand Up @@ -641,6 +653,11 @@ async function handleSessionsEnsure(
authPolicy: globalFlags.authPolicy,
timeoutMs: globalFlags.timeout,
verbose: globalFlags.verbose,
sessionOptions: {
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
},
});

if (result.created) {
Expand Down Expand Up @@ -1292,6 +1309,9 @@ function detectAgentToken(argv: string[]): AgentTokenScan {
token === "--auth-policy" ||
token === "--non-interactive-permissions" ||
token === "--format" ||
token === "--model" ||
token === "--allowed-tools" ||
token === "--max-turns" ||
token === "--timeout" ||
token === "--ttl" ||
token === "--file"
Expand All @@ -1305,6 +1325,9 @@ function detectAgentToken(argv: string[]): AgentTokenScan {
token.startsWith("--auth-policy=") ||
token.startsWith("--non-interactive-permissions=") ||
token.startsWith("--format=") ||
token.startsWith("--model=") ||
token.startsWith("--allowed-tools=") ||
token.startsWith("--max-turns=") ||
token.startsWith("--json-strict=") ||
token.startsWith("--timeout=") ||
token.startsWith("--ttl=") ||
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url";
import { main } from "./cli-core.js";

export { formatPromptSessionBannerLine } from "./cli-core.js";
export { parseTtlSeconds } from "./cli/flags.js";
export { parseAllowedTools, parseMaxTurns, parseTtlSeconds } from "./cli/flags.js";

function isCliEntrypoint(argv: string[]): boolean {
const entry = argv[1];
Expand Down
37 changes: 37 additions & 0 deletions src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export type GlobalFlags = PermissionFlags & {
ttl: number;
verbose?: boolean;
format: OutputFormat;
model?: string;
allowedTools?: string[];
maxTurns?: number;
};

export type PromptFlags = {
Expand Down Expand Up @@ -125,6 +128,30 @@ export function parseHistoryLimit(value: string): number {
return parsed;
}

export function parseAllowedTools(value: string): string[] {
const trimmed = value.trim();
if (trimmed.length === 0) {
return [];
}

const items = trimmed.split(",").map((item) => item.trim());
if (items.some((item) => item.length === 0)) {
throw new InvalidArgumentError(
"Allowed tools must be a comma-separated list without empty entries",
);
}

return items;
}

export function parseMaxTurns(value: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new InvalidArgumentError("Max turns must be a positive integer");
}
return parsed;
}

export function resolvePermissionMode(
flags: PermissionFlags,
defaultMode: PermissionMode,
Expand Down Expand Up @@ -165,6 +192,13 @@ export function addGlobalFlags(command: Command): Command {
parseNonInteractivePermissionPolicy,
)
.option("--format <fmt>", "Output format: text, json, quiet", parseOutputFormat)
.option("--model <id>", "Agent model id")
.option(
"--allowed-tools <list>",
'Allowed tool names as a comma-separated list (use "" for no tools)',
parseAllowedTools,
)
.option("--max-turns <count>", "Maximum turns for the session", parseMaxTurns)
.option(
"--json-strict",
"Strict JSON mode: requires --format json and suppresses non-JSON stderr output",
Expand Down Expand Up @@ -247,6 +281,9 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig)
ttl: opts.ttl ?? config.ttlMs ?? DEFAULT_QUEUE_OWNER_TTL_MS,
verbose,
format,
model: typeof opts.model === "string" ? parseNonEmptyValue("Model", opts.model) : undefined,
allowedTools: Array.isArray(opts.allowedTools) ? opts.allowedTools : undefined,
maxTurns: typeof opts.maxTurns === "number" ? opts.maxTurns : undefined,
approveAll: opts.approveAll ? true : undefined,
approveReads: opts.approveReads ? true : undefined,
denyAll: opts.denyAll ? true : undefined,
Expand Down
30 changes: 30 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,35 @@ function readEnvCredential(methodId: string): string | undefined {
return undefined;
}

function buildClaudeCodeOptionsMeta(
options: AcpClientOptions["sessionOptions"],
): Record<string, unknown> | undefined {
if (!options) {
return undefined;
}

const claudeCodeOptions: Record<string, unknown> = {};
if (typeof options.model === "string" && options.model.trim().length > 0) {
claudeCodeOptions.model = options.model;
}
if (Array.isArray(options.allowedTools)) {
claudeCodeOptions.allowedTools = [...options.allowedTools];
}
if (typeof options.maxTurns === "number") {
claudeCodeOptions.maxTurns = options.maxTurns;
}

if (Object.keys(claudeCodeOptions).length === 0) {
return undefined;
}

return {
claudeCode: {
options: claudeCodeOptions,
},
};
}

function buildAgentEnvironment(
authCredentials: Record<string, string> | undefined,
): NodeJS.ProcessEnv {
Expand Down Expand Up @@ -881,6 +910,7 @@ export class AcpClient {
const createPromise = connection.newSession({
cwd: asAbsoluteCwd(cwd),
mcpServers: [],
_meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions),
});
result = claudeAcp
? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs())
Expand Down
12 changes: 12 additions & 0 deletions src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ type TimedRunOptions = {
timeoutMs?: number;
};

export type SessionAgentOptions = {
model?: string;
allowedTools?: string[];
maxTurns?: number;
};

export type RunOnceOptions = {
agentCommand: string;
cwd: string;
Expand All @@ -105,6 +111,7 @@ export type RunOnceOptions = {
outputFormatter: OutputFormatter;
suppressSdkConsoleErrors?: boolean;
verbose?: boolean;
sessionOptions?: SessionAgentOptions;
} & TimedRunOptions;

export type SessionCreateOptions = {
Expand All @@ -116,6 +123,7 @@ export type SessionCreateOptions = {
authCredentials?: Record<string, string>;
authPolicy?: AuthPolicy;
verbose?: boolean;
sessionOptions?: SessionAgentOptions;
} & TimedRunOptions;

export type SessionSendOptions = {
Expand Down Expand Up @@ -144,6 +152,7 @@ export type SessionEnsureOptions = {
authPolicy?: AuthPolicy;
verbose?: boolean;
walkBoundary?: string;
sessionOptions?: SessionAgentOptions;
} & TimedRunOptions;

export type SessionCancelOptions = {
Expand Down Expand Up @@ -614,6 +623,7 @@ export async function runOnce(options: RunOnceOptions): Promise<RunPromptResult>
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
verbose: options.verbose,
onAcpOutputMessage: (_direction, message) => output.onAcpMessage(message),
sessionOptions: options.sessionOptions,
});

try {
Expand Down Expand Up @@ -659,6 +669,7 @@ export async function createSession(options: SessionCreateOptions): Promise<Sess
authCredentials: options.authCredentials,
authPolicy: options.authPolicy,
verbose: options.verbose,
sessionOptions: options.sessionOptions,
});

try {
Expand Down Expand Up @@ -742,6 +753,7 @@ export async function ensureSession(options: SessionEnsureOptions): Promise<Sess
authPolicy: options.authPolicy,
timeoutMs: options.timeoutMs,
verbose: options.verbose,
sessionOptions: options.sessionOptions,
});

return {
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ export type AcpClientOptions = {
authPolicy?: AuthPolicy;
suppressSdkConsoleErrors?: boolean;
verbose?: boolean;
sessionOptions?: {
model?: string;
allowedTools?: string[];
maxTurns?: number;
};
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onSessionUpdate?: (notification: SessionNotification) => void;
Expand Down
32 changes: 31 additions & 1 deletion test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { InvalidArgumentError } from "commander";
import { formatPromptSessionBannerLine, parseTtlSeconds } from "../src/cli.js";
import {
formatPromptSessionBannerLine,
parseAllowedTools,
parseMaxTurns,
parseTtlSeconds,
} from "../src/cli.js";
import { serializeSessionRecordForDisk } from "../src/session-persistence.js";
import type { SessionRecord } from "../src/types.js";
import {
Expand Down Expand Up @@ -115,6 +120,21 @@ test("parseTtlSeconds rejects negative values", () => {
assert.throws(() => parseTtlSeconds("-1"), InvalidArgumentError);
});

test("parseAllowedTools parses empty and comma-separated values", () => {
assert.deepEqual(parseAllowedTools(""), []);
assert.deepEqual(parseAllowedTools("Read,Grep, Glob"), ["Read", "Grep", "Glob"]);
});

test("parseAllowedTools rejects empty entries", () => {
assert.throws(() => parseAllowedTools("Read,,Grep"), InvalidArgumentError);
});

test("parseMaxTurns accepts positive integers and rejects invalid values", () => {
assert.equal(parseMaxTurns("3"), 3);
assert.throws(() => parseMaxTurns("0"), InvalidArgumentError);
assert.throws(() => parseMaxTurns("1.5"), InvalidArgumentError);
});

test("formatPromptSessionBannerLine prints single-line prompt banner for matching cwd", () => {
const record = makeSessionRecord({
acpxRecordId: "abc123",
Expand Down Expand Up @@ -179,6 +199,16 @@ test("CLI resolves unknown subcommand names as raw agent commands", async () =>
});
});

test("global passthrough flags are present in help output", async () => {
await withTempHome(async (homeDir) => {
const result = await runCli(["--help"], homeDir);
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /--model <id>/);
assert.match(result.stdout, /--allowed-tools <list>/);
assert.match(result.stdout, /--max-turns <count>/);
});
});

test("sessions new command is present in help output", async () => {
await withTempHome(async (homeDir) => {
const result = await runCli(["sessions", "--help"], homeDir);
Expand Down
34 changes: 34 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,40 @@ test("AcpClient handlePermissionRequest records approved decisions", async () =>
});
});

test("AcpClient createSession forwards claudeCode options in _meta", async () => {
const client = makeClient({
sessionOptions: {
model: "sonnet",
allowedTools: ["Read", "Grep"],
maxTurns: 12,
},
});

let capturedParams: Record<string, unknown> | undefined;
asInternals(client).connection = {
newSession: async (params: Record<string, unknown>) => {
capturedParams = params;
return { sessionId: "session-123" };
},
};

const result = await client.createSession("/tmp/acpx-client-meta");
assert.equal(result.sessionId, "session-123");
assert.deepEqual(capturedParams, {
cwd: "/tmp/acpx-client-meta",
mcpServers: [],
_meta: {
claudeCode: {
options: {
model: "sonnet",
allowedTools: ["Read", "Grep"],
maxTurns: 12,
},
},
},
});
});

test("AcpClient session update handling drains queued callbacks and swallows handler failures", async () => {
const notifications: string[] = [];
const client = makeClient({
Expand Down
Loading