diff --git a/CLAUDE.md b/CLAUDE.md
index 2934e3a..4ceb0f6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,8 +54,8 @@
- `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers.
- `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers.
- `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`.
-- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, and `onSessionEvent`.
-- `Session` helpers are `prompt(...)`, `send(...)`, and `onEvent(...)`.
+- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`.
+- `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`.
- Cleanup is `sdk.dispose()`.
### Docs Source Of Truth
@@ -86,6 +86,8 @@
- Regenerate `docs/openapi.json` when HTTP contracts change.
- Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation.
- Append blockers/decisions to `research/acp/friction.md` during ACP work.
+- `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`).
+- Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier.
- TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior.
- Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests.
diff --git a/docs/agent-capabilities.mdx b/docs/agent-capabilities.mdx
new file mode 100644
index 0000000..13f2723
--- /dev/null
+++ b/docs/agent-capabilities.mdx
@@ -0,0 +1,127 @@
+---
+title: "Agent Capabilities"
+description: "Models, modes, and thought levels supported by each agent."
+---
+
+Capabilities are subject to change as the agents are updated. See [Agent Sessions](/agent-sessions) for full session configuration API details.
+
+
+
+ _Last updated: March 5th, 2026. See [Generating a live report](#generating-a-live-report) for up-to-date reference._
+
+
+## Claude
+
+| Category | Values |
+|----------|--------|
+| **Models** | `default`, `sonnet`, `opus`, `haiku` |
+| **Modes** | `default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions` |
+| **Thought levels** | Unsupported |
+
+### Configuring Effort Level For Claude
+
+Claude does not natively support changing effort level after a session starts, so configure it in the filesystem before creating the session.
+
+```ts
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { SandboxAgent } from "sandbox-agent";
+
+const cwd = "/path/to/workspace";
+await mkdir(path.join(cwd, ".claude"), { recursive: true });
+await writeFile(
+ path.join(cwd, ".claude", "settings.json"),
+ JSON.stringify({ effortLevel: "high" }, null, 2),
+);
+
+const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" });
+await sdk.createSession({
+ agent: "claude",
+ sessionInit: { cwd, mcpServers: [] },
+});
+```
+
+
+
+1. `~/.claude/settings.json`
+2. `/.claude/settings.json`
+3. `/.claude/settings.local.json`
+
+
+
+## Codex
+
+| Category | Values |
+|----------|--------|
+| **Models** | `gpt-5.3-codex` (default), `gpt-5.3-codex-spark`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1-codex-mini` |
+| **Modes** | `read-only` (default), `auto`, `full-access` |
+| **Thought levels** | `low`, `medium`, `high` (default), `xhigh` |
+
+## OpenCode
+
+| Category | Values |
+|----------|--------|
+| **Models** | See below |
+| **Modes** | `build` (default), `plan` |
+| **Thought levels** | Unsupported |
+
+
+
+| Provider | Models |
+|----------|--------|
+| **Anthropic** | `anthropic/claude-3-5-haiku-20241022`, `anthropic/claude-3-5-haiku-latest`, `anthropic/claude-3-5-sonnet-20240620`, `anthropic/claude-3-5-sonnet-20241022`, `anthropic/claude-3-7-sonnet-20250219`, `anthropic/claude-3-7-sonnet-latest`, `anthropic/claude-3-haiku-20240307`, `anthropic/claude-3-opus-20240229`, `anthropic/claude-3-sonnet-20240229`, `anthropic/claude-haiku-4-5`, `anthropic/claude-haiku-4-5-20251001`, `anthropic/claude-opus-4-0`, `anthropic/claude-opus-4-1`, `anthropic/claude-opus-4-1-20250805`, `anthropic/claude-opus-4-20250514`, `anthropic/claude-opus-4-5`, `anthropic/claude-opus-4-5-20251101`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-0`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-sonnet-4-5-20250929` |
+| **OpenAI** | `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.3-codex` |
+| **Cerebras** | `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7` |
+| **OpenCode Zen** | `opencode/big-pickle`, `opencode/claude-3-5-haiku`, `opencode/claude-haiku-4-5`, `opencode/claude-opus-4-1`, `opencode/claude-opus-4-5`, `opencode/claude-opus-4-6`, `opencode/claude-sonnet-4`, `opencode/claude-sonnet-4-5`, `opencode/gemini-3-flash`, `opencode/gemini-3-pro` (default), `opencode/glm-4.6`, `opencode/glm-4.7`, `opencode/gpt-5`, `opencode/gpt-5-codex`, `opencode/gpt-5-nano`, `opencode/gpt-5.1`, `opencode/gpt-5.1-codex`, `opencode/gpt-5.1-codex-max`, `opencode/gpt-5.1-codex-mini`, `opencode/gpt-5.2`, `opencode/gpt-5.2-codex`, `opencode/kimi-k2`, `opencode/kimi-k2-thinking`, `opencode/kimi-k2.5`, `opencode/kimi-k2.5-free`, `opencode/minimax-m2.1`, `opencode/minimax-m2.1-free`, `opencode/trinity-large-preview-free` |
+
+
+
+## Cursor
+
+| Category | Values |
+|----------|--------|
+| **Models** | See below |
+| **Modes** | Unsupported |
+| **Thought levels** | Unsupported |
+
+
+
+| Group | Models |
+|-------|--------|
+| **Auto** | `auto` |
+| **Composer** | `composer-1.5`, `composer-1` |
+| **GPT-5.3 Codex** | `gpt-5.3-codex`, `gpt-5.3-codex-low`, `gpt-5.3-codex-high`, `gpt-5.3-codex-xhigh`, `gpt-5.3-codex-fast`, `gpt-5.3-codex-low-fast`, `gpt-5.3-codex-high-fast`, `gpt-5.3-codex-xhigh-fast` |
+| **GPT-5.2** | `gpt-5.2`, `gpt-5.2-high`, `gpt-5.2-codex`, `gpt-5.2-codex-low`, `gpt-5.2-codex-high`, `gpt-5.2-codex-xhigh`, `gpt-5.2-codex-fast`, `gpt-5.2-codex-low-fast`, `gpt-5.2-codex-high-fast`, `gpt-5.2-codex-xhigh-fast` |
+| **GPT-5.1** | `gpt-5.1-high`, `gpt-5.1-codex-max`, `gpt-5.1-codex-max-high` |
+| **Claude** | `opus-4.6-thinking` (default), `opus-4.6`, `opus-4.5`, `opus-4.5-thinking`, `sonnet-4.5`, `sonnet-4.5-thinking` |
+| **Other** | `gemini-3-pro`, `gemini-3-flash`, `grok` |
+
+
+
+## Amp
+
+| Category | Values |
+|----------|--------|
+| **Models** | `amp-default` |
+| **Modes** | `default`, `bypass` |
+| **Thought levels** | Unsupported |
+
+## Pi
+
+| Category | Values |
+|----------|--------|
+| **Models** | `default` |
+| **Modes** | Unsupported |
+| **Thought levels** | Unsupported |
+
+## Generating a live report
+
+Requires a running Sandbox Agent server. `--endpoint` defaults to `http://127.0.0.1:2468`.
+
+```bash
+sandbox-agent api agents report
+```
+
+
+ The live report reflects what the agent adapter returns for the current credentials. Some models may be gated by subscription (e.g. Claude's `opus` requires a paid plan) and will not appear in the report if the credentials don't have access.
+
diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx
index ac29b9f..a224acd 100644
--- a/docs/agent-sessions.mdx
+++ b/docs/agent-sessions.mdx
@@ -82,6 +82,49 @@ if (sessions.items.length > 0) {
}
```
+## Configure model, mode, and thought level
+
+Set the model, mode, or thought level on a session at creation time or after:
+
+```ts
+// At creation time
+const session = await sdk.createSession({
+ agent: "codex",
+ model: "gpt-5.3-codex",
+ mode: "auto",
+ thoughtLevel: "high",
+});
+```
+
+```ts
+// After creation
+await session.setModel("gpt-5.2-codex");
+await session.setMode("full-access");
+await session.setThoughtLevel("medium");
+```
+
+Query available modes:
+
+```ts
+const modes = await session.getModes();
+console.log(modes?.currentModeId, modes?.availableModes);
+```
+
+### Advanced config options
+
+For config options beyond model, mode, and thought level, use `getConfigOptions` to discover what the agent supports and `setConfigOption` to set any option by ID:
+
+```ts
+const options = await session.getConfigOptions();
+for (const opt of options) {
+ console.log(opt.id, opt.category, opt.type);
+}
+```
+
+```ts
+await session.setConfigOption("some-agent-option", "value");
+```
+
## Destroy a session
```ts
diff --git a/docs/cli.mdx b/docs/cli.mdx
index 9472a5e..fa6aa4e 100644
--- a/docs/cli.mdx
+++ b/docs/cli.mdx
@@ -167,6 +167,65 @@ Shared option:
```bash
sandbox-agent api agents list [--endpoint ]
+sandbox-agent api agents report [--endpoint ]
sandbox-agent api agents install [--reinstall] [--endpoint ]
```
+#### api agents list
+
+List all agents and their install status.
+
+```bash
+sandbox-agent api agents list
+```
+
+#### api agents report
+
+Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category.
+
+```bash
+sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .
+```
+
+Example output:
+
+```json
+{
+ "generatedAtMs": 1740000000000,
+ "endpoint": "http://127.0.0.1:2468",
+ "agents": [
+ {
+ "id": "claude",
+ "installed": true,
+ "models": {
+ "currentValue": "default",
+ "values": [
+ { "value": "default", "name": "Default" },
+ { "value": "sonnet", "name": "Sonnet" },
+ { "value": "opus", "name": "Opus" },
+ { "value": "haiku", "name": "Haiku" }
+ ]
+ },
+ "modes": {
+ "currentValue": "default",
+ "values": [
+ { "value": "default", "name": "Default" },
+ { "value": "acceptEdits", "name": "Accept Edits" },
+ { "value": "plan", "name": "Plan" },
+ { "value": "dontAsk", "name": "Don't Ask" },
+ { "value": "bypassPermissions", "name": "Bypass Permissions" }
+ ]
+ },
+ "thoughtLevels": { "values": [] }
+ }
+ ]
+}
+```
+
+See [Agent Capabilities](/agent-capabilities) for a full reference of supported models, modes, and thought levels per agent.
+
+#### api agents install
+
+```bash
+sandbox-agent api agents install codex --reinstall
+```
diff --git a/docs/docs.json b/docs/docs.json
index b2b3a6a..8f7a2ec 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -94,6 +94,7 @@
{
"group": "Reference",
"pages": [
+ "agent-capabilities",
"cli",
"inspector",
"opencode-compatibility",
diff --git a/docs/openapi.json b/docs/openapi.json
index d600fda..d6272b7 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -954,6 +954,8 @@
"tags": [
"v1"
],
+ "summary": "List all managed processes.",
+ "description": "Returns a list of all processes (running and exited) currently tracked\nby the runtime, sorted by process ID.",
"operationId": "get_v1_processes",
"responses": {
"200": {
@@ -982,6 +984,8 @@
"tags": [
"v1"
],
+ "summary": "Create a long-lived managed process.",
+ "description": "Spawns a new process with the given command and arguments. Supports both\npipe-based and PTY (tty) modes. Returns the process descriptor on success.",
"operationId": "post_v1_processes",
"requestBody": {
"content": {
@@ -1042,6 +1046,8 @@
"tags": [
"v1"
],
+ "summary": "Get process runtime configuration.",
+ "description": "Returns the current runtime configuration for the process management API,\nincluding limits for concurrency, timeouts, and buffer sizes.",
"operationId": "get_v1_processes_config",
"responses": {
"200": {
@@ -1070,6 +1076,8 @@
"tags": [
"v1"
],
+ "summary": "Update process runtime configuration.",
+ "description": "Replaces the runtime configuration for the process management API.\nValidates that all values are non-zero and clamps default timeout to max.",
"operationId": "post_v1_processes_config",
"requestBody": {
"content": {
@@ -1120,6 +1128,8 @@
"tags": [
"v1"
],
+ "summary": "Run a one-shot command.",
+ "description": "Executes a command to completion and returns its stdout, stderr, exit code,\nand duration. Supports configurable timeout and output size limits.",
"operationId": "post_v1_processes_run",
"requestBody": {
"content": {
@@ -1170,6 +1180,8 @@
"tags": [
"v1"
],
+ "summary": "Get a single process by ID.",
+ "description": "Returns the current state of a managed process including its status,\nPID, exit code, and creation/exit timestamps.",
"operationId": "get_v1_process",
"parameters": [
{
@@ -1219,6 +1231,8 @@
"tags": [
"v1"
],
+ "summary": "Delete a process record.",
+ "description": "Removes a stopped process from the runtime. Returns 409 if the process\nis still running; stop or kill it first.",
"operationId": "delete_v1_process",
"parameters": [
{
@@ -1273,6 +1287,8 @@
"tags": [
"v1"
],
+ "summary": "Write input to a process.",
+ "description": "Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).\nData can be encoded as base64, utf8, or text. Returns 413 if the decoded\npayload exceeds the configured `maxInputBytesPerRequest` limit.",
"operationId": "post_v1_process_input",
"parameters": [
{
@@ -1354,6 +1370,8 @@
"tags": [
"v1"
],
+ "summary": "Send SIGKILL to a process.",
+ "description": "Sends SIGKILL to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.",
"operationId": "post_v1_process_kill",
"parameters": [
{
@@ -1417,6 +1435,8 @@
"tags": [
"v1"
],
+ "summary": "Fetch process logs.",
+ "description": "Returns buffered log entries for a process. Supports filtering by stream\ntype, tail count, and sequence-based resumption. When `follow=true`,\nreturns an SSE stream that replays buffered entries then streams live output.",
"operationId": "get_v1_process_logs",
"parameters": [
{
@@ -1515,6 +1535,8 @@
"tags": [
"v1"
],
+ "summary": "Send SIGTERM to a process.",
+ "description": "Sends SIGTERM to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.",
"operationId": "post_v1_process_stop",
"parameters": [
{
@@ -1578,6 +1600,8 @@
"tags": [
"v1"
],
+ "summary": "Resize a process terminal.",
+ "description": "Sets the PTY window size (columns and rows) for a tty-mode process and\nsends SIGWINCH so the child process can adapt.",
"operationId": "post_v1_process_terminal_resize",
"parameters": [
{
@@ -1659,6 +1683,8 @@
"tags": [
"v1"
],
+ "summary": "Open an interactive WebSocket terminal session.",
+ "description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Streams raw PTY output as binary frames and accepts\nJSON control frames for input, resize, and close.",
"operationId": "get_v1_process_terminal_ws",
"parameters": [
{
@@ -2013,6 +2039,7 @@
"permission_denied",
"not_acceptable",
"unsupported_media_type",
+ "not_found",
"session_not_found",
"session_already_exists",
"mode_not_supported",
diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx
index 53a38f6..ffb1a6b 100644
--- a/docs/sdk-overview.mdx
+++ b/docs/sdk-overview.mdx
@@ -115,6 +115,25 @@ await restored.prompt([{ type: "text", text: "Continue from previous context." }
await sdk.destroySession(restored.id);
```
+## Session configuration
+
+Set model, mode, or thought level at creation or on an existing session:
+
+```ts
+const session = await sdk.createSession({
+ agent: "codex",
+ model: "gpt-5.3-codex",
+});
+
+await session.setModel("gpt-5.2-codex");
+await session.setMode("auto");
+
+const options = await session.getConfigOptions();
+const modes = await session.getModes();
+```
+
+See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
+
## Events
Subscribe to live events:
@@ -188,13 +207,3 @@ Parameters:
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
-## Types
-
-```ts
-import type {
- AgentInfo,
- HealthResponse,
- SessionEvent,
- SessionRecord,
-} from "sandbox-agent";
-```
diff --git a/scripts/agent-configs/dump.ts b/scripts/agent-configs/dump.ts
index 9df4d05..d0d759e 100644
--- a/scripts/agent-configs/dump.ts
+++ b/scripts/agent-configs/dump.ts
@@ -1,5 +1,6 @@
/**
- * Fetches model/mode lists from agent backends and writes them to resources/.
+ * Fetches model/mode/thought-level lists from agent backends and writes them
+ * to resources/.
*
* Usage:
* npx tsx dump.ts # Dump all agents
@@ -10,11 +11,24 @@
* Claude — Anthropic API (GET /v1/models?beta=true). Extracts API key from
* ANTHROPIC_API_KEY env. Falls back to aliases (default, sonnet, opus, haiku)
* on 401/403 or missing credentials.
+ * Modes are hardcoded (discovered by ACP session/set_mode probing).
+ * Claude does not implement session/set_config_option at all.
* Codex — Codex app-server JSON-RPC (model/list over stdio, paginated).
+ * Modes and thought levels are hardcoded (discovered from Codex's
+ * ACP session/new configOptions response).
* OpenCode — OpenCode HTTP server (GET {base_url}/config/providers, fallback /provider).
- * Model IDs formatted as {provider_id}/{model_id}.
+ * Model IDs formatted as {provider_id}/{model_id}. Modes hardcoded.
* Cursor — `cursor-agent models` CLI command. Parses the text output.
*
+ * Derivation of hardcoded values:
+ * When agents don't expose modes/thought levels through their model listing
+ * APIs, we discover them by ACP probing against a running sandbox-agent server:
+ * 1. Create an ACP session via session/new and inspect the configOptions and
+ * modes fields in the response.
+ * 2. Test session/set_mode with candidate mode IDs.
+ * 3. Test session/set_config_option with candidate config IDs and values.
+ * See /tmp/probe-agents.sh or /tmp/probe-agents.ts for example probe scripts.
+ *
* Output goes to resources/ alongside this script. These JSON files are committed
* to the repo and included in the sandbox-agent binary at compile time via include_str!.
*/
@@ -37,11 +51,19 @@ interface ModeEntry {
description?: string;
}
+interface ThoughtLevelEntry {
+ id: string;
+ name: string;
+ description?: string;
+}
+
interface AgentModelList {
defaultModel: string;
models: ModelEntry[];
defaultMode?: string;
modes?: ModeEntry[];
+ defaultThoughtLevel?: string;
+ thoughtLevels?: ThoughtLevelEntry[];
}
// ─── CLI ──────────────────────────────────────────────────────────────────────
@@ -100,8 +122,13 @@ function writeList(agent: string, list: AgentModelList) {
const filePath = path.join(RESOURCES_DIR, `${agent}.json`);
fs.writeFileSync(filePath, JSON.stringify(list, null, 2) + "\n");
const modeCount = list.modes?.length ?? 0;
+ const thoughtCount = list.thoughtLevels?.length ?? 0;
+ const extras = [
+ modeCount ? `${modeCount} modes` : null,
+ thoughtCount ? `${thoughtCount} thought levels` : null,
+ ].filter(Boolean).join(", ");
console.log(
- ` Wrote ${list.models.length} models${modeCount ? `, ${modeCount} modes` : ""} to ${filePath} (default: ${list.defaultModel})`
+ ` Wrote ${list.models.length} models${extras ? `, ${extras}` : ""} to ${filePath} (default: ${list.defaultModel})`
);
}
@@ -110,14 +137,28 @@ function writeList(agent: string, list: AgentModelList) {
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/models?beta=true";
const ANTHROPIC_VERSION = "2023-06-01";
+// Claude v0.20.0 (@zed-industries/claude-agent-acp) returns configOptions and
+// modes from session/new. Models and modes below match the ACP adapter source.
+// Note: `opus` is gated by subscription — it may not appear in session/new for
+// all credentials, but exists in the SDK model list. Thought levels are supported
+// by the Claude SDK (effort levels: low/medium/high/max for opus-4-6 and
+// sonnet-4-6) but the ACP adapter does not expose them as configOptions yet.
const CLAUDE_FALLBACK: AgentModelList = {
defaultModel: "default",
models: [
- { id: "default", name: "Default (recommended)" },
- { id: "opus", name: "Opus" },
+ { id: "default", name: "Default" },
{ id: "sonnet", name: "Sonnet" },
+ { id: "opus", name: "Opus" },
{ id: "haiku", name: "Haiku" },
],
+ defaultMode: "default",
+ modes: [
+ { id: "default", name: "Default" },
+ { id: "acceptEdits", name: "Accept Edits" },
+ { id: "plan", name: "Plan" },
+ { id: "dontAsk", name: "Don't Ask" },
+ { id: "bypassPermissions", name: "Bypass Permissions" },
+ ],
};
async function dumpClaude() {
@@ -185,6 +226,9 @@ async function dumpClaude() {
writeList("claude", {
defaultModel: defaultModel ?? models[0]?.id ?? "default",
models,
+ // Modes from Claude ACP adapter v0.20.0 session/new response.
+ defaultMode: "default",
+ modes: CLAUDE_FALLBACK.modes,
});
}
@@ -277,9 +321,26 @@ async function dumpCodex() {
models.sort((a, b) => a.id.localeCompare(b.id));
+ // Codex modes and thought levels come from its ACP session/new configOptions
+ // response (category: "mode" and category: "thought_level"). The model/list
+ // RPC only returns models, so modes/thought levels are hardcoded here based
+ // on probing Codex's session/new response.
writeList("codex", {
defaultModel: defaultModel ?? models[0]?.id ?? "",
models,
+ defaultMode: "read-only",
+ modes: [
+ { id: "read-only", name: "Read Only", description: "Codex can read files in the current workspace. Approval is required to edit files or access the internet." },
+ { id: "auto", name: "Default", description: "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files." },
+ { id: "full-access", name: "Full Access", description: "Codex can edit files outside this workspace and access the internet without asking for approval." },
+ ],
+ defaultThoughtLevel: "high",
+ thoughtLevels: [
+ { id: "low", name: "Low", description: "Fast responses with lighter reasoning" },
+ { id: "medium", name: "Medium", description: "Balances speed and reasoning depth for everyday tasks" },
+ { id: "high", name: "High", description: "Greater reasoning depth for complex problems" },
+ { id: "xhigh", name: "Xhigh", description: "Extra high reasoning depth for complex problems" },
+ ],
});
}
diff --git a/scripts/agent-configs/resources/claude.json b/scripts/agent-configs/resources/claude.json
index a9130dc..73162a2 100644
--- a/scripts/agent-configs/resources/claude.json
+++ b/scripts/agent-configs/resources/claude.json
@@ -3,19 +3,42 @@
"models": [
{
"id": "default",
- "name": "Default (recommended)"
- },
- {
- "id": "opus",
- "name": "Opus"
+ "name": "Default"
},
{
"id": "sonnet",
"name": "Sonnet"
},
+ {
+ "id": "opus",
+ "name": "Opus"
+ },
{
"id": "haiku",
"name": "Haiku"
}
+ ],
+ "defaultMode": "default",
+ "modes": [
+ {
+ "id": "default",
+ "name": "Default"
+ },
+ {
+ "id": "acceptEdits",
+ "name": "Accept Edits"
+ },
+ {
+ "id": "plan",
+ "name": "Plan"
+ },
+ {
+ "id": "dontAsk",
+ "name": "Don't Ask"
+ },
+ {
+ "id": "bypassPermissions",
+ "name": "Bypass Permissions"
+ }
]
}
diff --git a/scripts/agent-configs/resources/codex.json b/scripts/agent-configs/resources/codex.json
index 289f00a..e3cfdd1 100644
--- a/scripts/agent-configs/resources/codex.json
+++ b/scripts/agent-configs/resources/codex.json
@@ -2,24 +2,62 @@
"defaultModel": "gpt-5.3-codex",
"models": [
{
- "id": "gpt-5.1-codex-max",
- "name": "gpt-5.1-codex-max"
+ "id": "gpt-5.3-codex",
+ "name": "gpt-5.3-codex"
},
{
- "id": "gpt-5.1-codex-mini",
- "name": "gpt-5.1-codex-mini"
- },
- {
- "id": "gpt-5.2",
- "name": "gpt-5.2"
+ "id": "gpt-5.3-codex-spark",
+ "name": "GPT-5.3-Codex-Spark"
},
{
"id": "gpt-5.2-codex",
"name": "gpt-5.2-codex"
},
{
- "id": "gpt-5.3-codex",
- "name": "gpt-5.3-codex"
+ "id": "gpt-5.1-codex-max",
+ "name": "gpt-5.1-codex-max"
+ },
+ {
+ "id": "gpt-5.2",
+ "name": "gpt-5.2"
+ },
+ {
+ "id": "gpt-5.1-codex-mini",
+ "name": "gpt-5.1-codex-mini"
+ }
+ ],
+ "defaultMode": "read-only",
+ "modes": [
+ {
+ "id": "read-only",
+ "name": "Read Only"
+ },
+ {
+ "id": "auto",
+ "name": "Default"
+ },
+ {
+ "id": "full-access",
+ "name": "Full Access"
+ }
+ ],
+ "defaultThoughtLevel": "high",
+ "thoughtLevels": [
+ {
+ "id": "low",
+ "name": "Low"
+ },
+ {
+ "id": "medium",
+ "name": "Medium"
+ },
+ {
+ "id": "high",
+ "name": "High"
+ },
+ {
+ "id": "xhigh",
+ "name": "Xhigh"
}
]
}
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
index 65b8aa5..4f23bab 100644
--- a/sdks/typescript/src/client.ts
+++ b/sdks/typescript/src/client.ts
@@ -1,5 +1,6 @@
import {
AcpHttpClient,
+ AcpRpcError,
PROTOCOL_VERSION,
type AcpEnvelopeDirection,
type AnyMessage,
@@ -9,8 +10,12 @@ import {
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
+ type SessionConfigOption,
type SessionNotification,
+ type SessionModeState,
+ type SetSessionConfigOptionResponse,
type SetSessionConfigOptionRequest,
+ type SetSessionModeResponse,
type SetSessionModeRequest,
} from "acp-http-client";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts";
@@ -67,6 +72,9 @@ const DEFAULT_BASE_URL = "http://sandbox-agent";
const DEFAULT_REPLAY_MAX_EVENTS = 50;
const DEFAULT_REPLAY_MAX_CHARS = 12_000;
const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500;
+const SESSION_CANCEL_METHOD = "session/cancel";
+const MANUAL_CANCEL_ERROR =
+ "Manual session/cancel calls are not allowed. Use destroySession(sessionId) instead.";
const HEALTH_WAIT_MIN_DELAY_MS = 500;
const HEALTH_WAIT_MAX_DELAY_MS = 15_000;
const HEALTH_WAIT_LOG_AFTER_MS = 5_000;
@@ -109,12 +117,18 @@ export interface SessionCreateRequest {
id?: string;
agent: string;
sessionInit?: Omit;
+ model?: string;
+ mode?: string;
+ thoughtLevel?: string;
}
export interface SessionResumeOrCreateRequest {
id: string;
agent: string;
sessionInit?: Omit;
+ model?: string;
+ mode?: string;
+ thoughtLevel?: string;
}
export interface SessionSendOptions {
@@ -158,6 +172,64 @@ export class SandboxAgentError extends Error {
}
}
+export class UnsupportedSessionCategoryError extends Error {
+ readonly sessionId: string;
+ readonly category: string;
+ readonly availableCategories: string[];
+
+ constructor(sessionId: string, category: string, availableCategories: string[]) {
+ super(
+ `Session '${sessionId}' does not support category '${category}'. Available categories: ${availableCategories.join(", ") || "(none)"}`,
+ );
+ this.name = "UnsupportedSessionCategoryError";
+ this.sessionId = sessionId;
+ this.category = category;
+ this.availableCategories = availableCategories;
+ }
+}
+
+export class UnsupportedSessionValueError extends Error {
+ readonly sessionId: string;
+ readonly category: string;
+ readonly configId: string;
+ readonly requestedValue: string;
+ readonly allowedValues: string[];
+
+ constructor(
+ sessionId: string,
+ category: string,
+ configId: string,
+ requestedValue: string,
+ allowedValues: string[],
+ ) {
+ super(
+ `Session '${sessionId}' does not support value '${requestedValue}' for category '${category}' (configId='${configId}'). Allowed values: ${allowedValues.join(", ") || "(none)"}`,
+ );
+ this.name = "UnsupportedSessionValueError";
+ this.sessionId = sessionId;
+ this.category = category;
+ this.configId = configId;
+ this.requestedValue = requestedValue;
+ this.allowedValues = allowedValues;
+ }
+}
+
+export class UnsupportedSessionConfigOptionError extends Error {
+ readonly sessionId: string;
+ readonly configId: string;
+ readonly availableConfigIds: string[];
+
+ constructor(sessionId: string, configId: string, availableConfigIds: string[]) {
+ super(
+ `Session '${sessionId}' does not expose config option '${configId}'. Available configIds: ${availableConfigIds.join(", ") || "(none)"}`,
+ );
+ this.name = "UnsupportedSessionConfigOptionError";
+ this.sessionId = sessionId;
+ this.configId = configId;
+ this.availableConfigIds = availableConfigIds;
+ }
+}
+
export class Session {
private record: SessionRecord;
private readonly sandbox: SandboxAgent;
@@ -211,6 +283,38 @@ export class Session {
return response as PromptResponse;
}
+ async setMode(modeId: string): Promise {
+ const updated = await this.sandbox.setSessionMode(this.id, modeId);
+ this.apply(updated.session.toRecord());
+ return updated.response;
+ }
+
+ async setConfigOption(configId: string, value: string): Promise {
+ const updated = await this.sandbox.setSessionConfigOption(this.id, configId, value);
+ this.apply(updated.session.toRecord());
+ return updated.response;
+ }
+
+ async setModel(model: string): Promise {
+ const updated = await this.sandbox.setSessionModel(this.id, model);
+ this.apply(updated.session.toRecord());
+ return updated.response;
+ }
+
+ async setThoughtLevel(thoughtLevel: string): Promise {
+ const updated = await this.sandbox.setSessionThoughtLevel(this.id, thoughtLevel);
+ this.apply(updated.session.toRecord());
+ return updated.response;
+ }
+
+ async getConfigOptions(): Promise {
+ return this.sandbox.getSessionConfigOptions(this.id);
+ }
+
+ async getModes(): Promise {
+ return this.sandbox.getSessionModes(this.id);
+ }
+
onEvent(listener: SessionEventListener): () => void {
return this.sandbox.onSessionEvent(this.id, listener);
}
@@ -623,12 +727,35 @@ export class SandboxAgent {
lastConnectionId: live.connectionId,
createdAt: nowMs(),
sessionInit,
+ configOptions: cloneConfigOptions(response.configOptions),
+ modes: cloneModes(response.modes),
};
await this.persist.updateSession(record);
this.nextSessionEventIndexBySession.set(record.id, 1);
live.bindSession(record.id, record.agentSessionId);
- return this.upsertSessionHandle(record);
+ let session = this.upsertSessionHandle(record);
+
+ try {
+ if (request.mode) {
+ session = (await this.setSessionMode(session.id, request.mode)).session;
+ }
+ if (request.model) {
+ session = (await this.setSessionModel(session.id, request.model)).session;
+ }
+ if (request.thoughtLevel) {
+ session = (await this.setSessionThoughtLevel(session.id, request.thoughtLevel)).session;
+ }
+ } catch (err) {
+ try {
+ await this.destroySession(session.id);
+ } catch {
+ // Best-effort cleanup
+ }
+ throw err;
+ }
+
+ return session;
}
async resumeSession(id: string): Promise {
@@ -652,6 +779,8 @@ export class SandboxAgent {
agentSessionId: recreated.sessionId,
lastConnectionId: live.connectionId,
destroyedAt: undefined,
+ configOptions: cloneConfigOptions(recreated.configOptions),
+ modes: cloneModes(recreated.modes),
};
await this.persist.updateSession(updated);
@@ -664,16 +793,28 @@ export class SandboxAgent {
async resumeOrCreateSession(request: SessionResumeOrCreateRequest): Promise {
const existing = await this.persist.getSession(request.id);
if (existing) {
- return this.resumeSession(existing.id);
+ let session = await this.resumeSession(existing.id);
+ if (request.mode) {
+ session = (await this.setSessionMode(session.id, request.mode)).session;
+ }
+ if (request.model) {
+ session = (await this.setSessionModel(session.id, request.model)).session;
+ }
+ if (request.thoughtLevel) {
+ session = (await this.setSessionThoughtLevel(session.id, request.thoughtLevel)).session;
+ }
+ return session;
}
return this.createSession(request);
}
async destroySession(id: string): Promise {
- const existing = await this.persist.getSession(id);
- if (!existing) {
- throw new Error(`session '${id}' not found`);
+ try {
+ await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
+ } catch {
+ // Best-effort: agent may already be gone
}
+ const existing = await this.requireSessionRecord(id);
const updated: SessionRecord = {
...existing,
@@ -684,12 +825,181 @@ export class SandboxAgent {
return this.upsertSessionHandle(updated);
}
+ async setSessionMode(
+ sessionId: string,
+ modeId: string,
+ ): Promise<{ session: Session; response: SetSessionModeResponse | void }> {
+ const mode = modeId.trim();
+ if (!mode) {
+ throw new Error("setSessionMode requires a non-empty modeId");
+ }
+
+ const record = await this.requireSessionRecord(sessionId);
+ const knownModeIds = extractKnownModeIds(record.modes);
+ if (knownModeIds.length > 0 && !knownModeIds.includes(mode)) {
+ throw new UnsupportedSessionValueError(sessionId, "mode", "mode", mode, knownModeIds);
+ }
+
+ try {
+ return (await this.sendSessionMethodInternal(
+ sessionId,
+ "session/set_mode",
+ { modeId: mode },
+ {},
+ false,
+ )) as { session: Session; response: SetSessionModeResponse | void };
+ } catch (error) {
+ if (!(error instanceof AcpRpcError) || error.code !== -32601) {
+ throw error;
+ }
+ return this.setSessionCategoryValue(sessionId, "mode", mode);
+ }
+ }
+
+ async setSessionConfigOption(
+ sessionId: string,
+ configId: string,
+ value: string,
+ ): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
+ const resolvedConfigId = configId.trim();
+ if (!resolvedConfigId) {
+ throw new Error("setSessionConfigOption requires a non-empty configId");
+ }
+ const resolvedValue = value.trim();
+ if (!resolvedValue) {
+ throw new Error("setSessionConfigOption requires a non-empty value");
+ }
+
+ const options = await this.getSessionConfigOptions(sessionId);
+ const option = findConfigOptionById(options, resolvedConfigId);
+ if (!option) {
+ throw new UnsupportedSessionConfigOptionError(
+ sessionId,
+ resolvedConfigId,
+ options.map((item) => item.id),
+ );
+ }
+
+ const allowedValues = extractConfigValues(option);
+ if (allowedValues.length > 0 && !allowedValues.includes(resolvedValue)) {
+ throw new UnsupportedSessionValueError(
+ sessionId,
+ option.category ?? "uncategorized",
+ option.id,
+ resolvedValue,
+ allowedValues,
+ );
+ }
+
+ return (await this.sendSessionMethodInternal(
+ sessionId,
+ "session/set_config_option",
+ {
+ configId: resolvedConfigId,
+ value: resolvedValue,
+ },
+ {},
+ false,
+ )) as { session: Session; response: SetSessionConfigOptionResponse };
+ }
+
+ async setSessionModel(
+ sessionId: string,
+ model: string,
+ ): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
+ return this.setSessionCategoryValue(sessionId, "model", model);
+ }
+
+ async setSessionThoughtLevel(
+ sessionId: string,
+ thoughtLevel: string,
+ ): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
+ return this.setSessionCategoryValue(sessionId, "thought_level", thoughtLevel);
+ }
+
+ async getSessionConfigOptions(sessionId: string): Promise {
+ const record = await this.requireSessionRecord(sessionId);
+ const hydrated = await this.hydrateSessionConfigOptions(record.id, record);
+ return cloneConfigOptions(hydrated.configOptions) ?? [];
+ }
+
+ async getSessionModes(sessionId: string): Promise {
+ const record = await this.requireSessionRecord(sessionId);
+ return cloneModes(record.modes);
+ }
+
+ private async setSessionCategoryValue(
+ sessionId: string,
+ category: string,
+ value: string,
+ ): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
+ const resolvedValue = value.trim();
+ if (!resolvedValue) {
+ throw new Error(`setSession${toTitleCase(category)} requires a non-empty value`);
+ }
+
+ const options = await this.getSessionConfigOptions(sessionId);
+ const option = findConfigOptionByCategory(options, category);
+ if (!option) {
+ const categories = uniqueCategories(options);
+ throw new UnsupportedSessionCategoryError(sessionId, category, categories);
+ }
+
+ const allowedValues = extractConfigValues(option);
+ if (allowedValues.length > 0 && !allowedValues.includes(resolvedValue)) {
+ throw new UnsupportedSessionValueError(
+ sessionId,
+ category,
+ option.id,
+ resolvedValue,
+ allowedValues,
+ );
+ }
+
+ return this.setSessionConfigOption(sessionId, option.id, resolvedValue);
+ }
+
+ private async hydrateSessionConfigOptions(sessionId: string, snapshot: SessionRecord): Promise {
+ if (snapshot.configOptions !== undefined) {
+ return snapshot;
+ }
+
+ const info = await this.getAgent(snapshot.agent, { config: true });
+ const configOptions = normalizeSessionConfigOptions(info.configOptions) ?? [];
+ // Re-read the record from persistence so we merge against the latest
+ // state, not a stale snapshot captured before the network await.
+ const record = await this.persist.getSession(sessionId);
+ if (!record) {
+ return { ...snapshot, configOptions };
+ }
+ const updated: SessionRecord = {
+ ...record,
+ configOptions,
+ };
+ await this.persist.updateSession(updated);
+ return updated;
+ }
+
async sendSessionMethod(
sessionId: string,
method: string,
params: Record,
options: SessionSendOptions = {},
): Promise<{ session: Session; response: unknown }> {
+ return this.sendSessionMethodInternal(sessionId, method, params, options, false);
+ }
+
+ private async sendSessionMethodInternal(
+ sessionId: string,
+ method: string,
+ params: Record,
+ options: SessionSendOptions,
+ allowManagedCancel: boolean,
+ ): Promise<{ session: Session; response: unknown }> {
+ if (method === SESSION_CANCEL_METHOD && !allowManagedCancel) {
+ throw new Error(MANUAL_CANCEL_ERROR);
+ }
+
const record = await this.persist.getSession(sessionId);
if (!record) {
throw new Error(`session '${sessionId}' not found`);
@@ -699,10 +1009,11 @@ export class SandboxAgent {
if (!live.hasBoundSession(record.id, record.agentSessionId)) {
// The persisted session points at a stale connection; restore lazily.
const restored = await this.resumeSession(record.id);
- return this.sendSessionMethod(restored.id, method, params, options);
+ return this.sendSessionMethodInternal(restored.id, method, params, options, allowManagedCancel);
}
const response = await live.sendSessionMethod(record.id, method, params, options);
+ await this.persistSessionStateFromMethod(record.id, method, params, response);
const refreshed = await this.requireSessionRecord(record.id);
return {
session: this.upsertSessionHandle(refreshed),
@@ -710,6 +1021,83 @@ export class SandboxAgent {
};
}
+ private async persistSessionStateFromMethod(
+ sessionId: string,
+ method: string,
+ params: Record,
+ response: unknown,
+ ): Promise {
+ // Re-read the record from persistence so we merge against the latest
+ // state, not a stale snapshot captured before the RPC await.
+ const record = await this.persist.getSession(sessionId);
+ if (!record) {
+ return;
+ }
+
+ if (method === "session/set_config_option") {
+ const configId = typeof params.configId === "string" ? params.configId : null;
+ const value = typeof params.value === "string" ? params.value : null;
+ const updates: Partial = {};
+
+ const serverConfigOptions = extractConfigOptionsFromSetResponse(response);
+ if (serverConfigOptions) {
+ updates.configOptions = cloneConfigOptions(serverConfigOptions);
+ } else if (record.configOptions && configId && value) {
+ // Server didn't return configOptions — optimistically update the
+ // cached currentValue so subsequent getConfigOptions() reflects the
+ // change without a round-trip.
+ const updated = applyConfigOptionValue(record.configOptions, configId, value);
+ if (updated) {
+ updates.configOptions = updated;
+ }
+ }
+
+ // When a mode-category config option is set via set_config_option
+ // (fallback path from setSessionMode), keep modes.currentModeId in sync.
+ if (configId && value) {
+ const source = updates.configOptions ?? record.configOptions;
+ const option = source ? findConfigOptionById(source, configId) : null;
+ if (option?.category === "mode") {
+ const nextModes = applyCurrentMode(record.modes, value);
+ if (nextModes) {
+ updates.modes = nextModes;
+ }
+ }
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await this.persist.updateSession({ ...record, ...updates });
+ }
+ return;
+ }
+
+ if (method === "session/set_mode") {
+ const modeId = typeof params.modeId === "string" ? params.modeId : null;
+ if (!modeId) {
+ return;
+ }
+ const updates: Partial = {};
+ const nextModes = applyCurrentMode(record.modes, modeId);
+ if (nextModes) {
+ updates.modes = nextModes;
+ }
+ // Keep configOptions mode-category currentValue in sync with the new
+ // mode, mirroring the reverse sync in the set_config_option path above.
+ if (record.configOptions) {
+ const modeOption = findConfigOptionByCategory(record.configOptions, "mode");
+ if (modeOption) {
+ const updated = applyConfigOptionValue(record.configOptions, modeOption.id, modeId);
+ if (updated) {
+ updates.configOptions = updated;
+ }
+ }
+ }
+ if (Object.keys(updates).length > 0) {
+ await this.persist.updateSession({ ...record, ...updates });
+ }
+ }
+ }
+
onSessionEvent(sessionId: string, listener: SessionEventListener): () => void {
const listeners = this.eventListeners.get(sessionId) ?? new Set();
listeners.add(listener);
@@ -1024,6 +1412,7 @@ export class SandboxAgent {
};
await this.persist.insertEvent(event);
+ await this.persistSessionStateFromEvent(localSessionId, envelope, direction);
const listeners = this.eventListeners.get(localSessionId);
if (!listeners || listeners.size === 0) {
@@ -1035,6 +1424,56 @@ export class SandboxAgent {
}
}
+ private async persistSessionStateFromEvent(
+ sessionId: string,
+ envelope: AnyMessage,
+ direction: AcpEnvelopeDirection,
+ ): Promise {
+ if (direction !== "inbound") {
+ return;
+ }
+
+ if (envelopeMethod(envelope) !== "session/update") {
+ return;
+ }
+
+ const update = envelopeSessionUpdate(envelope);
+ if (!update || typeof update.sessionUpdate !== "string") {
+ return;
+ }
+
+ const record = await this.persist.getSession(sessionId);
+ if (!record) {
+ return;
+ }
+
+ if (update.sessionUpdate === "config_option_update") {
+ const configOptions = normalizeSessionConfigOptions(update.configOptions);
+ if (configOptions) {
+ await this.persist.updateSession({
+ ...record,
+ configOptions,
+ });
+ }
+ return;
+ }
+
+ if (update.sessionUpdate === "current_mode_update") {
+ const modeId = typeof update.currentModeId === "string" ? update.currentModeId : null;
+ if (!modeId) {
+ return;
+ }
+ const nextModes = applyCurrentMode(record.modes, modeId);
+ if (!nextModes) {
+ return;
+ }
+ await this.persist.updateSession({
+ ...record,
+ modes: nextModes,
+ });
+ }
+ }
+
private async allocateSessionEventIndex(sessionId: string): Promise {
await this.ensureSessionEventIndexSeeded(sessionId);
const nextIndex = this.nextSessionEventIndexBySession.get(sessionId) ?? 1;
@@ -1543,6 +1982,145 @@ async function readProblem(response: Response): Promise option.category === category);
+}
+
+function findConfigOptionById(
+ options: SessionConfigOption[],
+ configId: string,
+): SessionConfigOption | undefined {
+ return options.find((option) => option.id === configId);
+}
+
+function uniqueCategories(options: SessionConfigOption[]): string[] {
+ return [...new Set(options.map((option) => option.category).filter((value): value is string => !!value))].sort();
+}
+
+function extractConfigValues(option: SessionConfigOption): string[] {
+ if (!isRecord(option) || option.type !== "select" || !Array.isArray(option.options)) {
+ return [];
+ }
+
+ const values: string[] = [];
+ for (const entry of option.options as unknown[]) {
+ if (isRecord(entry) && typeof entry.value === "string") {
+ values.push(entry.value);
+ continue;
+ }
+ if (isRecord(entry) && Array.isArray(entry.options)) {
+ for (const nested of entry.options) {
+ if (isRecord(nested) && typeof nested.value === "string") {
+ values.push(nested.value);
+ }
+ }
+ }
+ }
+
+ return [...new Set(values)];
+}
+
+function extractKnownModeIds(modes: SessionModeState | null | undefined): string[] {
+ if (!modes || !Array.isArray(modes.availableModes)) {
+ return [];
+ }
+ return modes.availableModes
+ .map((mode) => (typeof mode.id === "string" ? mode.id : null))
+ .filter((value): value is string => !!value);
+}
+
+function applyCurrentMode(
+ modes: SessionModeState | null | undefined,
+ currentModeId: string,
+): SessionModeState | null {
+ if (modes && Array.isArray(modes.availableModes)) {
+ return {
+ ...modes,
+ currentModeId,
+ };
+ }
+ return {
+ currentModeId,
+ availableModes: [],
+ };
+}
+
+function applyConfigOptionValue(
+ configOptions: SessionConfigOption[],
+ configId: string,
+ value: string,
+): SessionConfigOption[] | null {
+ const idx = configOptions.findIndex((o) => o.id === configId);
+ if (idx === -1) {
+ return null;
+ }
+ const updated = cloneConfigOptions(configOptions) ?? [];
+ updated[idx] = { ...updated[idx]!, currentValue: value };
+ return updated;
+}
+
+function envelopeSessionUpdate(message: AnyMessage): Record | null {
+ if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
+ return null;
+ }
+ if (!("update" in message.params) || !isRecord(message.params.update)) {
+ return null;
+ }
+ return message.params.update;
+}
+
+function cloneConfigOptions(value: SessionConfigOption[] | null | undefined): SessionConfigOption[] | undefined {
+ if (!value) {
+ return undefined;
+ }
+ return JSON.parse(JSON.stringify(value)) as SessionConfigOption[];
+}
+
+function cloneModes(value: SessionModeState | null | undefined): SessionModeState | null {
+ if (!value) {
+ return null;
+ }
+ return JSON.parse(JSON.stringify(value)) as SessionModeState;
+}
+
+function isSessionConfigOption(value: unknown): value is SessionConfigOption {
+ return (
+ isRecord(value) &&
+ typeof value.id === "string" &&
+ typeof value.name === "string" &&
+ typeof value.type === "string"
+ );
+}
+
+function toTitleCase(input: string): string {
+ if (!input) {
+ return "";
+ }
+ return input
+ .split(/[_\s-]+/)
+ .filter(Boolean)
+ .map((part) => part[0]!.toUpperCase() + part.slice(1))
+ .join("");
+}
+
function formatHealthWaitError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts
index a89d796..18374fb 100644
--- a/sdks/typescript/src/generated/openapi.ts
+++ b/sdks/typescript/src/generated/openapi.ts
@@ -58,36 +58,105 @@ export interface paths {
get: operations["get_v1_health"];
};
"/v1/processes": {
+ /**
+ * List all managed processes.
+ * @description Returns a list of all processes (running and exited) currently tracked
+ * by the runtime, sorted by process ID.
+ */
get: operations["get_v1_processes"];
+ /**
+ * Create a long-lived managed process.
+ * @description Spawns a new process with the given command and arguments. Supports both
+ * pipe-based and PTY (tty) modes. Returns the process descriptor on success.
+ */
post: operations["post_v1_processes"];
};
"/v1/processes/config": {
+ /**
+ * Get process runtime configuration.
+ * @description Returns the current runtime configuration for the process management API,
+ * including limits for concurrency, timeouts, and buffer sizes.
+ */
get: operations["get_v1_processes_config"];
+ /**
+ * Update process runtime configuration.
+ * @description Replaces the runtime configuration for the process management API.
+ * Validates that all values are non-zero and clamps default timeout to max.
+ */
post: operations["post_v1_processes_config"];
};
"/v1/processes/run": {
+ /**
+ * Run a one-shot command.
+ * @description Executes a command to completion and returns its stdout, stderr, exit code,
+ * and duration. Supports configurable timeout and output size limits.
+ */
post: operations["post_v1_processes_run"];
};
"/v1/processes/{id}": {
+ /**
+ * Get a single process by ID.
+ * @description Returns the current state of a managed process including its status,
+ * PID, exit code, and creation/exit timestamps.
+ */
get: operations["get_v1_process"];
+ /**
+ * Delete a process record.
+ * @description Removes a stopped process from the runtime. Returns 409 if the process
+ * is still running; stop or kill it first.
+ */
delete: operations["delete_v1_process"];
};
"/v1/processes/{id}/input": {
+ /**
+ * Write input to a process.
+ * @description Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).
+ * Data can be encoded as base64, utf8, or text. Returns 413 if the decoded
+ * payload exceeds the configured `maxInputBytesPerRequest` limit.
+ */
post: operations["post_v1_process_input"];
};
"/v1/processes/{id}/kill": {
+ /**
+ * Send SIGKILL to a process.
+ * @description Sends SIGKILL to the process and optionally waits up to `waitMs`
+ * milliseconds for the process to exit before returning.
+ */
post: operations["post_v1_process_kill"];
};
"/v1/processes/{id}/logs": {
+ /**
+ * Fetch process logs.
+ * @description Returns buffered log entries for a process. Supports filtering by stream
+ * type, tail count, and sequence-based resumption. When `follow=true`,
+ * returns an SSE stream that replays buffered entries then streams live output.
+ */
get: operations["get_v1_process_logs"];
};
"/v1/processes/{id}/stop": {
+ /**
+ * Send SIGTERM to a process.
+ * @description Sends SIGTERM to the process and optionally waits up to `waitMs`
+ * milliseconds for the process to exit before returning.
+ */
post: operations["post_v1_process_stop"];
};
"/v1/processes/{id}/terminal/resize": {
+ /**
+ * Resize a process terminal.
+ * @description Sets the PTY window size (columns and rows) for a tty-mode process and
+ * sends SIGWINCH so the child process can adapt.
+ */
post: operations["post_v1_process_terminal_resize"];
};
"/v1/processes/{id}/terminal/ws": {
+ /**
+ * Open an interactive WebSocket terminal session.
+ * @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
+ * `access_token` query param for browser-based auth (WebSocket API cannot
+ * send custom headers). Streams raw PTY output as binary frames and accepts
+ * JSON control frames for input, resize, and close.
+ */
get: operations["get_v1_process_terminal_ws"];
};
}
@@ -166,7 +235,7 @@ export interface components {
agents: components["schemas"]["AgentInfo"][];
};
/** @enum {string} */
- ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
+ ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "not_found" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
FsActionResponse: {
path: string;
};
@@ -891,6 +960,11 @@ export interface operations {
};
};
};
+ /**
+ * List all managed processes.
+ * @description Returns a list of all processes (running and exited) currently tracked
+ * by the runtime, sorted by process ID.
+ */
get_v1_processes: {
responses: {
/** @description List processes */
@@ -907,6 +981,11 @@ export interface operations {
};
};
};
+ /**
+ * Create a long-lived managed process.
+ * @description Spawns a new process with the given command and arguments. Supports both
+ * pipe-based and PTY (tty) modes. Returns the process descriptor on success.
+ */
post_v1_processes: {
requestBody: {
content: {
@@ -940,6 +1019,11 @@ export interface operations {
};
};
};
+ /**
+ * Get process runtime configuration.
+ * @description Returns the current runtime configuration for the process management API,
+ * including limits for concurrency, timeouts, and buffer sizes.
+ */
get_v1_processes_config: {
responses: {
/** @description Current runtime process config */
@@ -956,6 +1040,11 @@ export interface operations {
};
};
};
+ /**
+ * Update process runtime configuration.
+ * @description Replaces the runtime configuration for the process management API.
+ * Validates that all values are non-zero and clamps default timeout to max.
+ */
post_v1_processes_config: {
requestBody: {
content: {
@@ -983,6 +1072,11 @@ export interface operations {
};
};
};
+ /**
+ * Run a one-shot command.
+ * @description Executes a command to completion and returns its stdout, stderr, exit code,
+ * and duration. Supports configurable timeout and output size limits.
+ */
post_v1_processes_run: {
requestBody: {
content: {
@@ -1010,6 +1104,11 @@ export interface operations {
};
};
};
+ /**
+ * Get a single process by ID.
+ * @description Returns the current state of a managed process including its status,
+ * PID, exit code, and creation/exit timestamps.
+ */
get_v1_process: {
parameters: {
path: {
@@ -1038,6 +1137,11 @@ export interface operations {
};
};
};
+ /**
+ * Delete a process record.
+ * @description Removes a stopped process from the runtime. Returns 409 if the process
+ * is still running; stop or kill it first.
+ */
delete_v1_process: {
parameters: {
path: {
@@ -1070,6 +1174,12 @@ export interface operations {
};
};
};
+ /**
+ * Write input to a process.
+ * @description Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).
+ * Data can be encoded as base64, utf8, or text. Returns 413 if the decoded
+ * payload exceeds the configured `maxInputBytesPerRequest` limit.
+ */
post_v1_process_input: {
parameters: {
path: {
@@ -1115,6 +1225,11 @@ export interface operations {
};
};
};
+ /**
+ * Send SIGKILL to a process.
+ * @description Sends SIGKILL to the process and optionally waits up to `waitMs`
+ * milliseconds for the process to exit before returning.
+ */
post_v1_process_kill: {
parameters: {
query?: {
@@ -1147,6 +1262,12 @@ export interface operations {
};
};
};
+ /**
+ * Fetch process logs.
+ * @description Returns buffered log entries for a process. Supports filtering by stream
+ * type, tail count, and sequence-based resumption. When `follow=true`,
+ * returns an SSE stream that replays buffered entries then streams live output.
+ */
get_v1_process_logs: {
parameters: {
query?: {
@@ -1185,6 +1306,11 @@ export interface operations {
};
};
};
+ /**
+ * Send SIGTERM to a process.
+ * @description Sends SIGTERM to the process and optionally waits up to `waitMs`
+ * milliseconds for the process to exit before returning.
+ */
post_v1_process_stop: {
parameters: {
query?: {
@@ -1217,6 +1343,11 @@ export interface operations {
};
};
};
+ /**
+ * Resize a process terminal.
+ * @description Sets the PTY window size (columns and rows) for a tty-mode process and
+ * sends SIGWINCH so the child process can adapt.
+ */
post_v1_process_terminal_resize: {
parameters: {
path: {
@@ -1262,6 +1393,13 @@ export interface operations {
};
};
};
+ /**
+ * Open an interactive WebSocket terminal session.
+ * @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
+ * `access_token` query param for browser-based auth (WebSocket API cannot
+ * send custom headers). Streams raw PTY output as binary frames and accepts
+ * JSON control frames for input, resize, and close.
+ */
get_v1_process_terminal_ws: {
parameters: {
query?: {
diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts
index 8273809..cf25645 100644
--- a/sdks/typescript/src/index.ts
+++ b/sdks/typescript/src/index.ts
@@ -3,6 +3,9 @@ export {
SandboxAgent,
SandboxAgentError,
Session,
+ UnsupportedSessionCategoryError,
+ UnsupportedSessionConfigOptionError,
+ UnsupportedSessionValueError,
} from "./client.ts";
export { AcpRpcError } from "acp-http-client";
diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts
index aa7a73a..3c0674b 100644
--- a/sdks/typescript/src/types.ts
+++ b/sdks/typescript/src/types.ts
@@ -1,4 +1,9 @@
-import type { AnyMessage, NewSessionRequest } from "acp-http-client";
+import type {
+ AnyMessage,
+ NewSessionRequest,
+ SessionConfigOption,
+ SessionModeState,
+} from "acp-http-client";
import type { components, operations } from "./generated/openapi.ts";
export type ProblemDetails = components["schemas"]["ProblemDetails"];
@@ -92,6 +97,8 @@ export interface SessionRecord {
createdAt: number;
destroyedAt?: number;
sessionInit?: Omit;
+ configOptions?: SessionConfigOption[];
+ modes?: SessionModeState | null;
}
export type SessionEventSender = "client" | "agent";
@@ -231,6 +238,12 @@ function cloneSessionRecord(session: SessionRecord): SessionRecord {
sessionInit: session.sessionInit
? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"])
: undefined,
+ configOptions: session.configOptions
+ ? (JSON.parse(JSON.stringify(session.configOptions)) as SessionRecord["configOptions"])
+ : undefined,
+ modes: session.modes
+ ? (JSON.parse(JSON.stringify(session.modes)) as SessionRecord["modes"])
+ : session.modes,
};
}
diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts
index 238c6cb..7243aa8 100644
--- a/sdks/typescript/tests/integration.test.ts
+++ b/sdks/typescript/tests/integration.test.ts
@@ -520,6 +520,127 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
});
+ it("blocks manual session/cancel and requires destroySession", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const session = await sdk.createSession({ agent: "mock" });
+
+ await expect(session.send("session/cancel")).rejects.toThrow(
+ "Use destroySession(sessionId) instead.",
+ );
+ await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
+ "Use destroySession(sessionId) instead.",
+ );
+
+ const destroyed = await sdk.destroySession(session.id);
+ expect(destroyed.destroyedAt).toBeDefined();
+
+ const reloaded = await sdk.getSession(session.id);
+ expect(reloaded?.destroyedAt).toBeDefined();
+
+ await sdk.dispose();
+ });
+
+ it("supports typed config helpers and createSession preconfiguration", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const session = await sdk.createSession({
+ agent: "mock",
+ model: "mock",
+ });
+
+ const options = await session.getConfigOptions();
+ expect(options.some((option) => option.category === "model")).toBe(true);
+
+ await expect(session.setModel("unknown-model")).rejects.toThrow("does not support value");
+
+ await sdk.dispose();
+ });
+
+ it("setModel happy path switches to a valid model", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const session = await sdk.createSession({ agent: "mock" });
+ await session.setModel("mock-fast");
+
+ const options = await session.getConfigOptions();
+ const modelOption = options.find((o) => o.category === "model");
+ expect(modelOption?.currentValue).toBe("mock-fast");
+
+ await sdk.dispose();
+ });
+
+ it("setMode happy path switches to a valid mode", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const session = await sdk.createSession({ agent: "mock" });
+ await session.setMode("plan");
+
+ const modes = await session.getModes();
+ expect(modes?.currentModeId).toBe("plan");
+
+ await sdk.dispose();
+ });
+
+ it("setThoughtLevel happy path switches to a valid thought level", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const session = await sdk.createSession({ agent: "mock" });
+ await session.setThoughtLevel("high");
+
+ const options = await session.getConfigOptions();
+ const thoughtOption = options.find((o) => o.category === "thought_level");
+ expect(thoughtOption?.currentValue).toBe("high");
+
+ await sdk.dispose();
+ });
+
+ it("setModel/setMode/setThoughtLevel can be changed multiple times", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const session = await sdk.createSession({ agent: "mock" });
+
+ // Model: mock → mock-fast → mock
+ await session.setModel("mock-fast");
+ expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock-fast");
+ await session.setModel("mock");
+ expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock");
+
+ // Mode: normal → plan → normal
+ await session.setMode("plan");
+ expect((await session.getModes())?.currentModeId).toBe("plan");
+ await session.setMode("normal");
+ expect((await session.getModes())?.currentModeId).toBe("normal");
+
+ // Thought level: low → high → medium → low
+ await session.setThoughtLevel("high");
+ expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("high");
+ await session.setThoughtLevel("medium");
+ expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("medium");
+ await session.setThoughtLevel("low");
+ expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("low");
+
+ await sdk.dispose();
+ });
+
it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
diff --git a/server/packages/agent-management/src/agents.rs b/server/packages/agent-management/src/agents.rs
index c36d6f8..cf48d11 100644
--- a/server/packages/agent-management/src/agents.rs
+++ b/server/packages/agent-management/src/agents.rs
@@ -78,7 +78,7 @@ impl AgentId {
fn agent_process_registry_id(self) -> Option<&'static str> {
match self {
- AgentId::Claude => Some("claude-code-acp"),
+ AgentId::Claude => Some("claude-acp"),
AgentId::Codex => Some("codex-acp"),
AgentId::Opencode => Some("opencode"),
AgentId::Amp => Some("amp-acp"),
@@ -90,7 +90,7 @@ impl AgentId {
fn agent_process_binary_hint(self) -> Option<&'static str> {
match self {
- AgentId::Claude => Some("claude-code-acp"),
+ AgentId::Claude => Some("claude-agent-acp"),
AgentId::Codex => Some("codex-acp"),
AgentId::Opencode => Some("opencode"),
AgentId::Amp => Some("amp-acp"),
@@ -606,7 +606,7 @@ impl AgentManager {
match agent {
AgentId::Claude => {
let package = fallback_npx_package(
- "@zed-industries/claude-code-acp",
+ "@zed-industries/claude-agent-acp",
options.agent_process_version.as_deref(),
);
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;
diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs
index 4443269..b1fc6bb 100644
--- a/server/packages/sandbox-agent/src/cli.rs
+++ b/server/packages/sandbox-agent/src/cli.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
use std::io::Write;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
@@ -24,7 +24,7 @@ use sandbox_agent_agent_credentials::{
ProviderCredentials,
};
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use thiserror::Error;
use tower_http::cors::{Any, CorsLayer};
@@ -220,6 +220,8 @@ pub struct AgentsArgs {
pub enum AgentsCommand {
/// List all agents and install status.
List(ClientArgs),
+ /// Emit JSON report of model/mode/thought options for all agents.
+ Report(ClientArgs),
/// Install or reinstall an agent.
Install(ApiInstallAgentArgs),
}
@@ -475,6 +477,7 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
let result = call_acp_extension(&ctx, ACP_EXTENSION_AGENT_LIST_METHOD, json!({}))?;
write_stdout_line(&serde_json::to_string_pretty(&result)?)
}
+ AgentsCommand::Report(args) => run_agents_report(args, cli),
AgentsCommand::Install(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let mut params = serde_json::Map::new();
@@ -498,6 +501,223 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
}
}
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct AgentListApiResponse {
+ agents: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct AgentListApiAgent {
+ id: String,
+ installed: bool,
+ #[serde(default)]
+ config_error: Option,
+ #[serde(default)]
+ config_options: Option>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct RawConfigOption {
+ #[serde(default)]
+ id: Option,
+ #[serde(default)]
+ category: Option,
+ #[serde(default)]
+ current_value: Option,
+ #[serde(default)]
+ options: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct RawConfigOptionChoice {
+ #[serde(default)]
+ value: Value,
+ #[serde(default)]
+ name: Option,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct AgentConfigReport {
+ generated_at_ms: u128,
+ endpoint: String,
+ agents: Vec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct AgentConfigReportEntry {
+ id: String,
+ installed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ config_error: Option,
+ models: AgentConfigCategoryReport,
+ modes: AgentConfigCategoryReport,
+ thought_levels: AgentConfigCategoryReport,
+}
+
+#[derive(Debug, Serialize, Default, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+struct AgentConfigCategoryReport {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ current_value: Option,
+ values: Vec,
+}
+
+#[derive(Debug, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+struct AgentConfigValueReport {
+ value: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ name: Option,
+}
+
+#[derive(Clone, Copy)]
+enum ConfigReportCategory {
+ Model,
+ Mode,
+ ThoughtLevel,
+}
+
+#[derive(Default)]
+struct CategoryAccumulator {
+ current_value: Option,
+ values: BTreeMap>,
+}
+
+impl CategoryAccumulator {
+ fn absorb(&mut self, option: &RawConfigOption) {
+ if self.current_value.is_none() {
+ self.current_value = config_value_to_string(option.current_value.as_ref());
+ }
+
+ for candidate in &option.options {
+ let Some(value) = config_value_to_string(Some(&candidate.value)) else {
+ continue;
+ };
+ let name = candidate
+ .name
+ .as_ref()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ let entry = self.values.entry(value).or_insert(None);
+ if entry.is_none() && name.is_some() {
+ *entry = name;
+ }
+ }
+ }
+
+ fn into_report(mut self) -> AgentConfigCategoryReport {
+ if let Some(current) = self.current_value.clone() {
+ self.values.entry(current).or_insert(None);
+ }
+ AgentConfigCategoryReport {
+ current_value: self.current_value,
+ values: self
+ .values
+ .into_iter()
+ .map(|(value, name)| AgentConfigValueReport { value, name })
+ .collect(),
+ }
+ }
+}
+
+fn run_agents_report(args: &ClientArgs, cli: &CliConfig) -> Result<(), CliError> {
+ let ctx = ClientContext::new(cli, args)?;
+ let response = ctx.get(&format!("{API_PREFIX}/agents?config=true"))?;
+ let status = response.status();
+ let text = response.text()?;
+
+ if !status.is_success() {
+ print_error_body(&text)?;
+ return Err(CliError::HttpStatus(status));
+ }
+
+ let parsed: AgentListApiResponse = serde_json::from_str(&text)?;
+ let report = build_agent_config_report(parsed, &ctx.endpoint);
+ write_stdout_line(&serde_json::to_string_pretty(&report)?)
+}
+
+fn build_agent_config_report(input: AgentListApiResponse, endpoint: &str) -> AgentConfigReport {
+ let generated_at_ms = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_millis())
+ .unwrap_or(0);
+
+ let agents = input
+ .agents
+ .into_iter()
+ .map(|agent| {
+ let mut model = CategoryAccumulator::default();
+ let mut mode = CategoryAccumulator::default();
+ let mut thought_level = CategoryAccumulator::default();
+
+ for option_value in agent.config_options.unwrap_or_default() {
+ let Ok(option) = serde_json::from_value::(option_value) else {
+ continue;
+ };
+ let Some(category) = option
+ .category
+ .as_deref()
+ .or(option.id.as_deref())
+ .and_then(classify_report_category)
+ else {
+ continue;
+ };
+
+ match category {
+ ConfigReportCategory::Model => model.absorb(&option),
+ ConfigReportCategory::Mode => mode.absorb(&option),
+ ConfigReportCategory::ThoughtLevel => thought_level.absorb(&option),
+ }
+ }
+
+ AgentConfigReportEntry {
+ id: agent.id,
+ installed: agent.installed,
+ config_error: agent.config_error,
+ models: model.into_report(),
+ modes: mode.into_report(),
+ thought_levels: thought_level.into_report(),
+ }
+ })
+ .collect();
+
+ AgentConfigReport {
+ generated_at_ms,
+ endpoint: endpoint.to_string(),
+ agents,
+ }
+}
+
+fn classify_report_category(raw: &str) -> Option {
+ let normalized = raw
+ .trim()
+ .to_ascii_lowercase()
+ .replace('-', "_")
+ .replace(' ', "_");
+
+ match normalized.as_str() {
+ "model" | "model_id" => Some(ConfigReportCategory::Model),
+ "mode" | "agent_mode" => Some(ConfigReportCategory::Mode),
+ "thought" | "thoughtlevel" | "thought_level" | "thinking" | "thinking_level"
+ | "reasoning" | "reasoning_effort" => Some(ConfigReportCategory::ThoughtLevel),
+ _ => None,
+ }
+}
+
+fn config_value_to_string(value: Option<&Value>) -> Option {
+ match value {
+ Some(Value::String(value)) => Some(value.clone()),
+ Some(Value::Null) | None => None,
+ Some(other) => Some(other.to_string()),
+ }
+}
+
fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Result {
let server_id = unique_cli_server_id("cli-ext");
let initialize_path = build_acp_server_path(&server_id, Some("mock"))?;
@@ -1219,4 +1439,96 @@ mod tests {
.expect("build request");
assert!(request.headers().get("last-event-id").is_none());
}
+
+ #[test]
+ fn classify_report_category_supports_common_aliases() {
+ assert!(matches!(
+ classify_report_category("model"),
+ Some(ConfigReportCategory::Model)
+ ));
+ assert!(matches!(
+ classify_report_category("mode"),
+ Some(ConfigReportCategory::Mode)
+ ));
+ assert!(matches!(
+ classify_report_category("thought_level"),
+ Some(ConfigReportCategory::ThoughtLevel)
+ ));
+ assert!(matches!(
+ classify_report_category("reasoning_effort"),
+ Some(ConfigReportCategory::ThoughtLevel)
+ ));
+ assert!(classify_report_category("arbitrary").is_none());
+ }
+
+ #[test]
+ fn build_agent_config_report_extracts_model_mode_and_thought() {
+ let response = AgentListApiResponse {
+ agents: vec![AgentListApiAgent {
+ id: "codex".to_string(),
+ installed: true,
+ config_error: None,
+ config_options: Some(vec![
+ json!({
+ "id": "model",
+ "category": "model",
+ "currentValue": "gpt-5",
+ "options": [
+ {"value": "gpt-5", "name": "GPT-5"},
+ {"value": "gpt-5-mini", "name": "GPT-5 mini"}
+ ]
+ }),
+ json!({
+ "id": "mode",
+ "category": "mode",
+ "currentValue": "default",
+ "options": [
+ {"value": "default", "name": "Default"},
+ {"value": "plan", "name": "Plan"}
+ ]
+ }),
+ json!({
+ "id": "thought",
+ "category": "thought_level",
+ "currentValue": "medium",
+ "options": [
+ {"value": "low", "name": "Low"},
+ {"value": "medium", "name": "Medium"},
+ {"value": "high", "name": "High"}
+ ]
+ }),
+ ]),
+ }],
+ };
+
+ let report = build_agent_config_report(response, "http://127.0.0.1:2468");
+ let agent = report.agents.first().expect("agent report");
+
+ assert_eq!(agent.id, "codex");
+ assert_eq!(agent.models.current_value.as_deref(), Some("gpt-5"));
+ assert_eq!(agent.modes.current_value.as_deref(), Some("default"));
+ assert_eq!(
+ agent.thought_levels.current_value.as_deref(),
+ Some("medium")
+ );
+
+ let model_values: Vec<&str> = agent
+ .models
+ .values
+ .iter()
+ .map(|item| item.value.as_str())
+ .collect();
+ assert!(model_values.contains(&"gpt-5"));
+ assert!(model_values.contains(&"gpt-5-mini"));
+
+ let thought_values: Vec<&str> = agent
+ .thought_levels
+ .values
+ .iter()
+ .map(|item| item.value.as_str())
+ .collect();
+ assert!(thought_values.contains(&"low"));
+ assert!(thought_values.contains(&"medium"));
+ assert!(thought_values.contains(&"high"));
+ }
}
diff --git a/server/packages/sandbox-agent/src/router/support.rs b/server/packages/sandbox-agent/src/router/support.rs
index 21dded4..8a1ecf0 100644
--- a/server/packages/sandbox-agent/src/router/support.rs
+++ b/server/packages/sandbox-agent/src/router/support.rs
@@ -147,6 +147,9 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec {
AgentId::Codex => CODEX.clone(),
AgentId::Opencode => OPENCODE.clone(),
AgentId::Cursor => CURSOR.clone(),
+ // Amp returns empty configOptions from session/new but exposes modes via
+ // the `modes` field. The model is hardcoded. Modes discovered from ACP
+ // session/new response (amp-acp v0.7.0).
AgentId::Amp => vec![
json!({
"id": "model",
@@ -163,12 +166,10 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec {
"name": "Mode",
"category": "mode",
"type": "select",
- "currentValue": "smart",
+ "currentValue": "default",
"options": [
- { "value": "smart", "name": "Smart" },
- { "value": "deep", "name": "Deep" },
- { "value": "free", "name": "Free" },
- { "value": "rush", "name": "Rush" }
+ { "value": "default", "name": "Default" },
+ { "value": "bypass", "name": "Bypass" }
]
}),
],
@@ -182,41 +183,76 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec {
{ "value": "default", "name": "Default" }
]
})],
- AgentId::Mock => vec![json!({
- "id": "model",
- "name": "Model",
- "category": "model",
- "type": "select",
- "currentValue": "mock",
- "options": [
- { "value": "mock", "name": "Mock" }
- ]
- })],
+ AgentId::Mock => vec![
+ json!({
+ "id": "model",
+ "name": "Model",
+ "category": "model",
+ "type": "select",
+ "currentValue": "mock",
+ "options": [
+ { "value": "mock", "name": "Mock" },
+ { "value": "mock-fast", "name": "Mock Fast" }
+ ]
+ }),
+ json!({
+ "id": "mode",
+ "name": "Mode",
+ "category": "mode",
+ "type": "select",
+ "currentValue": "normal",
+ "options": [
+ { "value": "normal", "name": "Normal" },
+ { "value": "plan", "name": "Plan" }
+ ]
+ }),
+ json!({
+ "id": "thought_level",
+ "name": "Thought Level",
+ "category": "thought_level",
+ "type": "select",
+ "currentValue": "low",
+ "options": [
+ { "value": "low", "name": "Low" },
+ { "value": "medium", "name": "Medium" },
+ { "value": "high", "name": "High" }
+ ]
+ }),
+ ],
}
}
/// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into
/// ACP `SessionConfigOption` values. The JSON format is:
/// ```json
-/// { "defaultModel": "...", "models": [{id, name}], "defaultMode?": "...", "modes?": [{id, name}] }
+/// {
+/// "defaultModel": "...", "models": [{id, name}],
+/// "defaultMode?": "...", "modes?": [{id, name}],
+/// "defaultThoughtLevel?": "...", "thoughtLevels?": [{id, name}]
+/// }
/// ```
+///
+/// Note: Claude and Codex don't report configOptions from `session/new`, so these
+/// JSON resource files are the source of truth for the capabilities report.
+/// Claude modes (plan, default) were discovered via manual ACP probing —
+/// `session/set_mode` works but `session/set_config_option` is not implemented.
+/// Codex modes/thought levels were discovered from its `session/new` response.
fn parse_agent_config(json_str: &str) -> Vec {
#[derive(serde::Deserialize)]
struct AgentConfig {
#[serde(rename = "defaultModel")]
default_model: String,
- models: Vec,
+ models: Vec,
#[serde(rename = "defaultMode")]
default_mode: Option,
- modes: Option>,
+ modes: Option>,
+ #[serde(rename = "defaultThoughtLevel")]
+ default_thought_level: Option,
+ #[serde(rename = "thoughtLevels")]
+ thought_levels: Option>,
}
#[derive(serde::Deserialize)]
- struct ModelEntry {
- id: String,
- name: String,
- }
- #[derive(serde::Deserialize)]
- struct ModeEntry {
+ struct ConfigEntry {
id: String,
name: String,
}
@@ -242,7 +278,7 @@ fn parse_agent_config(json_str: &str) -> Vec {
"name": "Mode",
"category": "mode",
"type": "select",
- "currentValue": config.default_mode.unwrap_or_else(|| modes[0].id.clone()),
+ "currentValue": config.default_mode.or_else(|| modes.first().map(|m| m.id.clone())).unwrap_or_default(),
"options": modes.iter().map(|m| json!({
"value": m.id,
"name": m.name,
@@ -250,6 +286,20 @@ fn parse_agent_config(json_str: &str) -> Vec {
}));
}
+ if let Some(thought_levels) = config.thought_levels {
+ options.push(json!({
+ "id": "thought_level",
+ "name": "Thought Level",
+ "category": "thought_level",
+ "type": "select",
+ "currentValue": config.default_thought_level.or_else(|| thought_levels.first().map(|t| t.id.clone())).unwrap_or_default(),
+ "options": thought_levels.iter().map(|t| json!({
+ "value": t.id,
+ "name": t.name,
+ })).collect::>(),
+ }));
+ }
+
options
}