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
30 changes: 16 additions & 14 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,21 @@ or close a PR if you run it against a live repository.

All global options:

| Option | Description | Details |
| ---------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- |
| `--agent <command>` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. |
| `--cwd <dir>` | Working directory | Defaults to current directory. Stored as absolute path for scoping. |
| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. |
| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. |
| `--deny-all` | Deny all permissions | Permission mode `deny-all`. |
| `--format <fmt>` | Output format | `text` (default), `json`, `quiet`. |
| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. |
| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. |
| `--non-interactive-permissions <policy>` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. |
| `--timeout <seconds>` | Max wait time for agent response | Must be positive. Decimal seconds allowed. |
| `--ttl <seconds>` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. |
| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. |
| Option | Description | Details |
| ---------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--agent <command>` | Raw ACP agent command (escape hatch) | Do not combine with positional agent token. |
| `--cwd <dir>` | Working directory | Defaults to current directory. Stored as absolute path for scoping. |
| `--approve-all` | Auto-approve all permissions | Permission mode `approve-all`. |
| `--approve-reads` | Auto-approve reads/searches, prompt for others | Default permission mode. |
| `--deny-all` | Deny all permissions | Permission mode `deny-all`. |
| `--format <fmt>` | Output format | `text` (default), `json`, `quiet`. |
| `--suppress-reads` | Suppress read file contents | Replaces raw read payloads with `[read output suppressed]`. |
| `--json-strict` | Strict JSON mode | Requires `--format json`; suppresses non-JSON stderr output. |
| `--non-interactive-permissions <policy>` | Non-TTY prompt policy | `deny` (default) or `fail` when approval prompt cannot be shown. |
| `--timeout <seconds>` | Max wait time for agent response | Must be positive. Decimal seconds allowed. |
| `--ttl <seconds>` | Queue owner idle TTL before shutdown | Default `300`. `0` disables TTL. |
| `--model <id>` | Set agent model | Passed through to agent-specific session creation metadata when applicable; if the agent advertises models, `acpx` also applies it via ACP `session/set_model`. |
| `--verbose` | Enable verbose logs | Prints ACP/debug details to stderr. |

Permission flags are mutually exclusive. Using more than one of `--approve-all`, `--approve-reads`, `--deny-all` is a usage error.

Expand Down Expand Up @@ -287,6 +288,7 @@ Behavior:
- Calls ACP `session/set_config_option`.
- Routes through queue-owner IPC when an owner is active.
- Falls back to a direct client reconnect when no owner is running.
- **`set model <id>`**: Intercepted to call `session/set_model` instead. Some agents support `session/set_model` but not `session/set_config_option` for model changes; routing through the dedicated method ensures broad compatibility.

## `sessions` subcommand

Expand Down
7 changes: 5 additions & 2 deletions skills/acpx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,13 @@ Behavior:
- Runs a single prompt in a temporary ACP session
- Does not reuse or save persistent session state

### Cancel / Mode / Config
### Cancel / Mode / Config / Model

```bash
acpx codex cancel
acpx codex set-mode auto
acpx codex set thought_level high
acpx codex set model gpt-5.4
```

Behavior:
Expand All @@ -153,8 +154,9 @@ Behavior:
- `set-mode`: calls ACP `session/set_mode`.
- `set-mode` mode ids are adapter-defined; unsupported values are rejected by the adapter (often `Invalid params`).
- `set`: calls ACP `session/set_config_option`.
- For codex, `--model <id>` is applied after session creation via `session/set_config_option`.
- For codex, `thought_level` is accepted as a compatibility alias for codex-acp `reasoning_effort`.
- `--model <id>`: passed through to agent-specific session creation metadata when applicable; if the agent advertises models, `acpx` also applies it via `session/set_model`.
- `set model <id>`: calls `session/set_model`. This is the generic ACP method for mid-session model switching.
- `set-mode`/`set` route through queue-owner IPC when active, otherwise reconnect directly.

### Sessions
Expand Down Expand Up @@ -200,6 +202,7 @@ Behavior:
- `--suppress-reads`: suppress raw read-file contents while preserving the selected format
- `--timeout <seconds>`: max wait time (positive number)
- `--ttl <seconds>`: queue owner idle TTL before shutdown (default `300`, `0` disables TTL)
- `--model <id>`: request an agent model during session creation; when the agent advertises models, `acpx` also applies it via `session/set_model`
- `--verbose`: verbose ACP/debug logs to stderr

Permission flags are mutually exclusive.
Expand Down
64 changes: 64 additions & 0 deletions src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,32 @@ function printSetModeResultByFormat(
process.stdout.write(`mode set: ${modeId}\n`);
}

function printSetModelResultByFormat(
modelId: string,
result: { record: SessionRecord; resumed: boolean },
format: OutputFormat,
): void {
if (
emitJsonResult(format, {
action: "model_set",
modelId,
resumed: result.resumed,
acpxRecordId: result.record.acpxRecordId,
acpxSessionId: result.record.acpSessionId,
agentSessionId: result.record.agentSessionId,
})
) {
return;
}

if (format === "quiet") {
process.stdout.write(`${modelId}\n`);
return;
}

process.stdout.write(`model set: ${modelId}\n`);
}

function printSetConfigOptionResultByFormat(
configId: string,
value: string,
Expand Down Expand Up @@ -526,6 +552,40 @@ async function handleSetMode(
printSetModeResultByFormat(modeId, result, globalFlags.format);
}

async function handleSetModel(
explicitAgentName: string | undefined,
modelId: string,
flags: StatusFlags,
command: Command,
config: ResolvedAcpxConfig,
): Promise<void> {
const globalFlags = resolveGlobalFlags(command, config);
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
const { setSessionModel } = await loadSessionModule();
const record = await findRoutedSessionOrThrow(
agent.agentCommand,
agent.agentName,
agent.cwd,
resolveSessionNameFromFlags(flags, command),
);
const result = await setSessionModel({
sessionId: record.acpxRecordId,
modelId,
mcpServers: config.mcpServers,
nonInteractivePermissions: globalFlags.nonInteractivePermissions,
authCredentials: config.auth,
authPolicy: globalFlags.authPolicy,
timeoutMs: globalFlags.timeout,
verbose: globalFlags.verbose,
});

if (globalFlags.verbose && result.loadError) {
process.stderr.write(`[acpx] loadSession failed, started fresh session: ${result.loadError}\n`);
}

printSetModelResultByFormat(modelId, result, globalFlags.format);
}

async function handleSetConfigOption(
explicitAgentName: string | undefined,
configId: string,
Expand All @@ -536,6 +596,10 @@ async function handleSetConfigOption(
): Promise<void> {
const globalFlags = resolveGlobalFlags(command, config);
const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config);
if (configId === "model") {
await handleSetModel(explicitAgentName, value, flags, command, config);
return;
}
const resolvedConfigId = resolveCompatibleConfigId(agent, configId);
const { setSessionConfigOption } = await loadSessionModule();
const record = await findRoutedSessionOrThrow(
Expand Down
10 changes: 10 additions & 0 deletions src/cli/status-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export async function handleStatus(
process.stdout.write(`agent: ${agent.agentCommand}\n`);
process.stdout.write("pid: -\n");
process.stdout.write("status: no-session\n");
process.stdout.write("model: -\n");
process.stdout.write("mode: -\n");
process.stdout.write("uptime: -\n");
process.stdout.write("lastPromptTime: -\n");
return;
Expand All @@ -78,6 +80,9 @@ export async function handleStatus(
agentCommand: record.agentCommand,
pid: health.pid ?? record.pid ?? null,
status: running ? "running" : "dead",
model: record.acpx?.current_model_id ?? null,
mode: record.acpx?.current_mode_id ?? null,
availableModels: record.acpx?.available_models ?? null,
uptime: running ? (formatUptime(record.agentStartedAt) ?? null) : null,
lastPromptTime: record.lastPromptAt ?? null,
exitCode: running ? null : (record.lastAgentExitCode ?? null),
Expand All @@ -91,6 +96,9 @@ export async function handleStatus(
status: running ? "alive" : "dead",
pid: payload.pid ?? undefined,
summary: running ? "queue owner healthy" : "queue owner unavailable",
model: payload.model ?? undefined,
mode: payload.mode ?? undefined,
availableModels: payload.availableModels ?? undefined,
uptime: payload.uptime ?? undefined,
lastPromptTime: payload.lastPromptTime ?? undefined,
exitCode: payload.exitCode ?? undefined,
Expand All @@ -115,6 +123,8 @@ export async function handleStatus(
process.stdout.write(`agent: ${payload.agentCommand}\n`);
process.stdout.write(`pid: ${payload.pid ?? "-"}\n`);
process.stdout.write(`status: ${payload.status}\n`);
process.stdout.write(`model: ${payload.model ?? "-"}\n`);
process.stdout.write(`mode: ${payload.mode ?? "-"}\n`);
process.stdout.write(`uptime: ${payload.uptime ?? "-"}\n`);
process.stdout.write(`lastPromptTime: ${payload.lastPromptTime ?? "-"}\n`);
if (payload.status === "dead") {
Expand Down
56 changes: 42 additions & 14 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import {
type WaitForTerminalExitResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
type SessionModelState,
} from "@agentclientprotocol/sdk";
import { extractAcpError } from "./acp-error-shapes.js";
import { isSessionUpdateNotification } from "./acp-jsonrpc.js";
import { isCodexAcpCommand } from "./codex-compat.js";
import {
AgentDisconnectedError,
AgentSpawnError,
Expand Down Expand Up @@ -86,10 +86,12 @@ type LoadSessionOptions = {
export type SessionCreateResult = {
sessionId: string;
agentSessionId?: string;
models?: SessionModelState;
};

export type SessionLoadResult = {
agentSessionId?: string;
models?: SessionModelState;
};

type AgentDisconnectReason = "process_exit" | "process_close" | "pipe_close" | "connection_close";
Expand Down Expand Up @@ -762,7 +764,7 @@ function formatSessionControlAcpSummary(acp: {
}

function maybeWrapSessionControlError(
method: "session/set_mode" | "session/set_config_option",
method: "session/set_mode" | "session/set_config_option" | "session/set_model",
error: unknown,
context?: string,
): unknown {
Expand Down Expand Up @@ -1190,7 +1192,6 @@ export class AcpClient {
const connection = this.getConnection();
const { command, args } = splitCommandLine(this.options.agentCommand);
const claudeAcp = isClaudeAcpCommand(command, args);
const codexAcp = isCodexAcpCommand(command, args);

let result: Awaited<ReturnType<typeof connection.newSession>>;
try {
Expand All @@ -1215,21 +1216,11 @@ export class AcpClient {
}

this.loadedSessionId = result.sessionId;
if (
codexAcp &&
typeof this.options.sessionOptions?.model === "string" &&
this.options.sessionOptions.model.trim().length > 0
) {
await this.setSessionConfigOption(
result.sessionId,
"model",
this.options.sessionOptions.model,
);
}

return {
sessionId: result.sessionId,
agentSessionId: extractRuntimeSessionId(result._meta),
models: result.models ?? undefined,
};
}

Expand Down Expand Up @@ -1271,8 +1262,10 @@ export class AcpClient {
}

this.loadedSessionId = sessionId;

return {
agentSessionId: extractRuntimeSessionId(response?._meta),
models: response?.models ?? undefined,
};
}

Expand Down Expand Up @@ -1360,6 +1353,41 @@ export class AcpClient {
}
}

async setSessionModel(sessionId: string, modelId: string): Promise<void> {
const connection = this.getConnection();
try {
await this.runConnectionRequest(() =>
connection.unstable_setSessionModel({
sessionId,
modelId,
}),
);
} catch (error) {
const wrapped = maybeWrapSessionControlError(
"session/set_model",
error,
`for model "${modelId}"`,
);
if (wrapped !== error) {
throw wrapped;
}
const acp = extractAcpError(error);
const summary = acp
? formatSessionControlAcpSummary(acp)
: error instanceof Error
? error.message
: String(error);
if (error instanceof Error) {
throw new Error(`Failed session/set_model for model "${modelId}": ${summary}`, {
cause: error,
});
}
throw new Error(`Failed session/set_model for model "${modelId}": ${summary}`, {
cause: error,
});
}
}

async cancel(sessionId: string): Promise<void> {
const connection = this.getConnection();
this.cancellingSessionIds.add(sessionId);
Expand Down
11 changes: 11 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ export class SessionModeReplayError extends AcpxOperationalError {
}
}

export class SessionModelReplayError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "RUNTIME",
detailCode: "SESSION_MODEL_REPLAY_FAILED",
origin: "acp",
...options,
});
}
}

export class ClaudeAcpSessionCreateTimeoutError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
Expand Down
17 changes: 17 additions & 0 deletions src/queue-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export type QueueTask = {
export type QueueOwnerControlHandlers = {
cancelPrompt: () => Promise<boolean>;
setSessionMode: (modeId: string, timeoutMs?: number) => Promise<void>;
setSessionModel: (modelId: string, timeoutMs?: number) => Promise<void>;
setSessionConfigOption: (
configId: string,
value: string,
Expand Down Expand Up @@ -408,6 +409,22 @@ export class SessionQueueOwner {
return;
}

if (request.type === "set_model") {
this.handleControlRequest({
socket,
requestId: request.requestId,
run: async () => {
await this.controlHandlers.setSessionModel(request.modelId, request.timeoutMs);
return {
type: "set_model_result",
requestId: request.requestId,
modelId: request.modelId,
};
},
});
return;
}

if (request.type === "set_config_option") {
this.handleControlRequest({
socket,
Expand Down
Loading