Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,11 @@ async function handlePrompt(
promptRetries: globalFlags.promptRetries,
verbose: globalFlags.verbose,
waitForCompletion: flags.wait !== false,
sessionOptions: {
model: globalFlags.model,
allowedTools: globalFlags.allowedTools,
maxTurns: globalFlags.maxTurns,
},
});

if ("queued" in result) {
Expand Down
53 changes: 53 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,21 @@ export class AcpClient {
this.eventHandlers = {};
}

updateSessionOptions(update: Partial<NonNullable<AcpClientOptions["sessionOptions"]>>): void {
if (!this.options.sessionOptions) {
this.options.sessionOptions = {};
}
if (update.model !== undefined) {
this.options.sessionOptions.model = update.model;
}
if (update.allowedTools !== undefined) {
this.options.sessionOptions.allowedTools = update.allowedTools;
}
if (update.maxTurns !== undefined) {
this.options.sessionOptions.maxTurns = update.maxTurns;
}
}

updateRuntimeOptions(options: {
permissionMode?: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
Expand Down Expand Up @@ -1249,6 +1264,7 @@ export class AcpClient {
sessionId,
cwd: asAbsoluteCwd(cwd),
mcpServers: this.options.mcpServers ?? [],
_meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions),
}),
);

Expand Down Expand Up @@ -1336,6 +1352,43 @@ export class AcpClient {
value: string,
): Promise<SetSessionConfigOptionResponse> {
const connection = this.getConnection();
// For model changes: prefer session/set_config_option (supports alias resolution
// via resolveModelPreference on Claude adapter) then fall back to session/set_model
// for adapters like Droid that only support the dedicated method.
if (configId === "model") {
try {
return await this.runConnectionRequest(() =>
connection.setSessionConfigOption({
sessionId,
configId,
value,
}),
);
} catch (error) {
const acp = extractAcpError(error);
if (acp && isLikelySessionControlUnsupportedError(acp)) {
// Adapter doesn't support set_config_option — fall back to set_model
try {
await this.runConnectionRequest(() =>
connection.unstable_setSessionModel({ sessionId, modelId: value }),
);
return { configOptions: [] };
} catch (fallbackError) {
throw maybeWrapSessionControlError(
"session/set_model",
fallbackError,
`for model="${value}"`,
);
}
}
throw maybeWrapSessionControlError(
"session/set_config_option",
error,
`for "model"="${value}"`,
);
}
}

try {
return await this.runConnectionRequest(() =>
connection.setSessionConfigOption({
Expand Down
16 changes: 16 additions & 0 deletions src/queue-owner-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ export function parseQueueOwnerPayload(raw: string): QueueOwnerRuntimeOptions {
options.promptRetries = Math.max(0, Math.round(record.promptRetries));
}

const sessionOpts = asRecord(record.sessionOptions);
if (sessionOpts) {
options.sessionOptions = {};
if (typeof sessionOpts.model === "string" && sessionOpts.model.trim().length > 0) {
options.sessionOptions.model = sessionOpts.model;
}
if (Array.isArray(sessionOpts.allowedTools)) {
options.sessionOptions.allowedTools = sessionOpts.allowedTools.filter(
(t): t is string => typeof t === "string",
);
}
if (typeof sessionOpts.maxTurns === "number" && Number.isFinite(sessionOpts.maxTurns)) {
options.sessionOptions.maxTurns = Math.max(1, Math.round(sessionOpts.maxTurns));
}
}

return options;
}

Expand Down
89 changes: 87 additions & 2 deletions src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,25 @@ function sessionOptionsFromRecord(record: SessionRecord): SessionAgentOptions |
return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined;
}

function mergeSessionOptions(
preferred: SessionAgentOptions | undefined,
fallback: SessionAgentOptions | undefined,
): SessionAgentOptions | undefined {
const merged: SessionAgentOptions = {
...fallback,
};
if (preferred?.model !== undefined) {
merged.model = preferred.model;
}
if (preferred?.allowedTools !== undefined) {
merged.allowedTools = preferred.allowedTools;
}
if (preferred?.maxTurns !== undefined) {
merged.maxTurns = preferred.maxTurns;
}
return Object.keys(merged).length > 0 ? merged : undefined;
}

function persistSessionOptions(
record: SessionRecord,
options: SessionAgentOptions | undefined,
Expand Down Expand Up @@ -228,6 +247,7 @@ export type SessionSendOptions = {
maxQueueDepth?: number;
client?: AcpClient;
promptRetries?: number;
sessionOptions?: SessionAgentOptions;
} & TimedRunOptions;

export type SessionEnsureOptions = {
Expand Down Expand Up @@ -338,6 +358,7 @@ type RunSessionPromptOptions = {
suppressSdkConsoleErrors?: boolean;
verbose?: boolean;
promptRetries?: number;
sessionOptions?: SessionAgentOptions;
onClientAvailable?: (controller: ActiveSessionController) => void;
onClientClosed?: () => void;
onPromptActive?: () => Promise<void> | void;
Expand Down Expand Up @@ -541,6 +562,7 @@ async function runQueuedTask(
authPolicy?: AuthPolicy;
suppressSdkConsoleErrors?: boolean;
promptRetries?: number;
sessionOptions?: SessionAgentOptions;
onClientAvailable?: (controller: ActiveSessionController) => void;
onClientClosed?: () => void;
onPromptActive?: () => Promise<void> | void;
Expand All @@ -566,12 +588,22 @@ async function runQueuedTask(
suppressSdkConsoleErrors: task.suppressSdkConsoleErrors ?? options.suppressSdkConsoleErrors,
verbose: options.verbose,
promptRetries: options.promptRetries,
sessionOptions: options.sessionOptions,
onClientAvailable: options.onClientAvailable,
onClientClosed: options.onClientClosed,
onPromptActive: options.onPromptActive,
client: options.sharedClient,
});

// Persist the record after each prompt so that control commands
// (e.g. set_config_option for model changes) see the updated acpSessionId
// when the session was recreated due to a loadSession fallback.
if (result.record) {
await writeSessionRecord(result.record).catch(() => {
// best effort — control commands will still work via sharedClient
});
}

if (task.waitForCompletion) {
task.send({
type: "result",
Expand Down Expand Up @@ -665,7 +697,7 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
authPolicy: options.authPolicy,
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
verbose: options.verbose,
sessionOptions: sessionOptionsFromRecord(record),
sessionOptions: mergeSessionOptions(options.sessionOptions, sessionOptionsFromRecord(record)),
});
client.updateRuntimeOptions({
permissionMode: options.permissionMode,
Expand Down Expand Up @@ -771,6 +803,14 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
);
}

if (options.sessionOptions?.model) {
await client.setSessionConfigOption(
activeSessionId,
"model",
options.sessionOptions.model,
);
}

output.setContext({
sessionId: record.acpxRecordId,
});
Expand Down Expand Up @@ -1257,7 +1297,10 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
authPolicy: options.authPolicy,
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
verbose: options.verbose,
sessionOptions: sessionOptionsFromRecord(sessionRecord),
sessionOptions: mergeSessionOptions(
options.sessionOptions,
sessionOptionsFromRecord(sessionRecord),
),
});
const ttlMs = normalizeQueueOwnerTtlMs(options.ttlMs);
const maxQueueDepth = Math.max(1, Math.round(options.maxQueueDepth ?? 16));
Expand Down Expand Up @@ -1291,6 +1334,25 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
});
},
setSessionConfigOptionFallback: async (configId: string, value: string, timeoutMs?: number) => {
// If the sharedClient has a reusable session, route through it directly
// instead of creating a temporary connection that doesn't affect the live session.
const currentRecord = await resolveSessionRecord(options.sessionId);
if (sharedClient.hasReusableSession(currentRecord.acpSessionId)) {
const response = await withTimeout(
(async () =>
await sharedClient.setSessionConfigOption(
currentRecord.acpSessionId,
configId,
value,
))(),
timeoutMs,
);
if (configId === "model") {
sharedClient.updateSessionOptions({ model: value });
}
return response;
}

const result = await runSessionSetConfigOptionDirect({
sessionRecordId: options.sessionId,
configId,
Expand All @@ -1302,6 +1364,10 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
timeoutMs,
verbose: options.verbose,
});
// Update sharedClient's sessionOptions so future reconnections use the new model.
if (configId === "model") {
sharedClient.updateSessionOptions({ model: value });
}
return result.response;
},
});
Expand Down Expand Up @@ -1405,6 +1471,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
authPolicy: options.authPolicy,
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
promptRetries: options.promptRetries,
sessionOptions: options.sessionOptions,
onClientAvailable: setActiveController,
onClientClosed: clearActiveController,
onPromptActive: async () => {
Expand Down Expand Up @@ -1446,6 +1513,24 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
export async function sendSession(options: SessionSendOptions): Promise<SessionSendOutcome> {
const waitForCompletion = options.waitForCompletion !== false;

// If a model is requested and an owner is already running, update the model
// BEFORE submitting the prompt (fresh sessions are handled via trySetModel in createSession).
if (options.sessionOptions?.model) {
await trySetConfigOptionOnRunningOwner(
options.sessionId,
"model",
options.sessionOptions.model,
options.timeoutMs,
options.verbose,
).catch((err: unknown) => {
if (options.verbose) {
process.stderr.write(
`[acpx] warning: failed to pre-set model on running owner: ${formatErrorMessage(err)}\n`,
);
}
});
}

const queuedToOwner = await submitToRunningOwner(options, waitForCompletion);
if (queuedToOwner) {
return queuedToOwner;
Expand Down
23 changes: 22 additions & 1 deletion src/session-runtime/prompt-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
writeSessionRecord,
} from "../session-persistence.js";
import { withInterrupt, withTimeout } from "../session-runtime-helpers.js";
import type { SessionAgentOptions } from "../session-runtime.js";
import type {
AuthPolicy,
McpServer,
Expand Down Expand Up @@ -58,6 +59,25 @@ function sessionOptionsFromRecord(record: SessionRecord):
return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined;
}

function mergeSessionOptions(
preferred: SessionAgentOptions | undefined,
fallback: SessionAgentOptions | undefined,
): SessionAgentOptions | undefined {
const merged: SessionAgentOptions = {
...fallback,
};
if (preferred?.model !== undefined) {
merged.model = preferred.model;
}
if (preferred?.allowedTools !== undefined) {
merged.allowedTools = preferred.allowedTools;
}
if (preferred?.maxTurns !== undefined) {
merged.maxTurns = preferred.maxTurns;
}
return Object.keys(merged).length > 0 ? merged : undefined;
}

type WithConnectedSessionOptions<T> = {
sessionRecordId: string;
mcpServers?: McpServer[];
Expand All @@ -67,6 +87,7 @@ type WithConnectedSessionOptions<T> = {
authPolicy?: AuthPolicy;
timeoutMs?: number;
verbose?: boolean;
sessionOptions?: SessionAgentOptions;
onClientAvailable?: (controller: ActiveSessionController) => void;
onClientClosed?: () => void;
run: (client: AcpClient, sessionId: string, record: SessionRecord) => Promise<T>;
Expand All @@ -92,7 +113,7 @@ async function withConnectedSession<T>(
authCredentials: options.authCredentials,
authPolicy: options.authPolicy,
verbose: options.verbose,
sessionOptions: sessionOptionsFromRecord(record),
sessionOptions: mergeSessionOptions(options.sessionOptions, sessionOptionsFromRecord(record)),
});
let activeSessionIdForControl = record.acpSessionId;
let notifiedClientAvailable = false;
Expand Down
4 changes: 4 additions & 0 deletions src/session-runtime/queue-owner-process.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import type { SessionAgentOptions } from "../session-runtime.js";
import type {
AuthPolicy,
McpServer,
Expand All @@ -19,6 +20,7 @@ export type QueueOwnerRuntimeOptions = {
ttlMs?: number;
maxQueueDepth?: number;
promptRetries?: number;
sessionOptions?: SessionAgentOptions;
};

type SessionSendLike = {
Expand All @@ -33,6 +35,7 @@ type SessionSendLike = {
ttlMs?: number;
maxQueueDepth?: number;
promptRetries?: number;
sessionOptions?: SessionAgentOptions;
};

export function sanitizeQueueOwnerExecArgv(
Expand Down Expand Up @@ -131,6 +134,7 @@ export function queueOwnerRuntimeOptionsFromSend(
ttlMs: options.ttlMs,
maxQueueDepth: options.maxQueueDepth,
promptRetries: options.promptRetries,
sessionOptions: options.sessionOptions,
};
}

Expand Down
Loading