feat: add configuration for model, mode, and thought level (#205)

* feat: add configuration for model, mode, and thought level

* docs: document Claude effort-level filesystem config

* fix: prevent panic on empty modes/thoughtLevels in parse_agent_config

Use `.first()` with safe fallback instead of direct `[0]` index access,
which would panic if the Vec is empty and no default is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden session lifecycle and align cli.mdx example with claude.json

- destroySession: wrap session/cancel RPC in try/catch so local cleanup
  always succeeds even when the agent is unreachable
- createSession/resumeOrCreateSession: clean up the remote session if
  post-creation config calls (setMode/setModel/setThoughtLevel) fail,
  preventing leaked orphan sessions
- cli.mdx: fix example output to match current claude.json (model name,
  model order, and populated modes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden session lifecycle and align config persistence logic

- resumeOrCreateSession: Remove destroy-on-error for the resume path. Config
  errors now propagate without destroying a pre-existing session. The destroy
  pattern remains in createSession (where the session is newly created and has
  no prior state to preserve).

- setSessionMode fallback: When session/set_mode returns -32601 and the
  fallback uses session/set_config_option, now keep modes.currentModeId
  in sync with the updated currentValue. Prevents stale cached state in
  getModes() when the fallback path is used.

- persistSessionStateFromMethod: Re-read the record from persistence instead
  of using a stale pre-await snapshot. Prevents race conditions where
  concurrent session/update events (processed by persistSessionStateFromEvent)
  are silently overwritten by optimistic updates.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix: correct doc examples with valid Codex modes and update stable API list

- Replace invalid Codex mode values ("plan", "build") with valid ones
  ("auto", "full-access") in agent-sessions.mdx and sdk-overview.mdx
- Update CLAUDE.md stable method enumerations to include new session
  config methods (setSessionMode, setSessionModel, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add OpenAPI annotations for process endpoints and fix config persistence race

Add summary/description to all process management endpoint specs and the
not_found error type. Fix hydrateSessionConfigOptions to re-read from
persistence after the network call, and sync mode-category configOptions
on session/update current_mode_update events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-06 00:24:32 -08:00 committed by GitHub
parent e7343e14bd
commit c91791f88d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1675 additions and 70 deletions

View file

@ -54,8 +54,8 @@
- `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers. - `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. - `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(...)`. - `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`.
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, and `onSessionEvent`. - Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`.
- `Session` helpers are `prompt(...)`, `send(...)`, and `onEvent(...)`. - `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`.
- Cleanup is `sdk.dispose()`. - Cleanup is `sdk.dispose()`.
### Docs Source Of Truth ### Docs Source Of Truth
@ -86,6 +86,8 @@
- Regenerate `docs/openapi.json` when HTTP contracts change. - Regenerate `docs/openapi.json` when HTTP contracts change.
- Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation. - Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation.
- Append blockers/decisions to `research/acp/friction.md` during ACP work. - 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. - 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. - Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests.

127
docs/agent-capabilities.mdx Normal file
View file

@ -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.
<Info>
_Last updated: March 5th, 2026. See [Generating a live report](#generating-a-live-report) for up-to-date reference._
</Info>
## 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: [] },
});
```
<Accordion title="Supported file locations (highest precedence last)">
1. `~/.claude/settings.json`
2. `<session cwd>/.claude/settings.json`
3. `<session cwd>/.claude/settings.local.json`
</Accordion>
## 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 |
<Accordion title="See all models">
| 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` |
</Accordion>
## Cursor
| Category | Values |
|----------|--------|
| **Models** | See below |
| **Modes** | Unsupported |
| **Thought levels** | Unsupported |
<Accordion title="See all models">
| 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` |
</Accordion>
## 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
```
<Note>
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.
</Note>

View file

@ -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 ## Destroy a session
```ts ```ts

View file

@ -167,6 +167,65 @@ Shared option:
```bash ```bash
sandbox-agent api agents list [--endpoint <URL>] sandbox-agent api agents list [--endpoint <URL>]
sandbox-agent api agents report [--endpoint <URL>]
sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>] sandbox-agent api agents install <AGENT> [--reinstall] [--endpoint <URL>]
``` ```
#### 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
```

View file

@ -94,6 +94,7 @@
{ {
"group": "Reference", "group": "Reference",
"pages": [ "pages": [
"agent-capabilities",
"cli", "cli",
"inspector", "inspector",
"opencode-compatibility", "opencode-compatibility",

View file

@ -954,6 +954,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "get_v1_processes",
"responses": { "responses": {
"200": { "200": {
@ -982,6 +984,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "post_v1_processes",
"requestBody": { "requestBody": {
"content": { "content": {
@ -1042,6 +1046,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "get_v1_processes_config",
"responses": { "responses": {
"200": { "200": {
@ -1070,6 +1076,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "post_v1_processes_config",
"requestBody": { "requestBody": {
"content": { "content": {
@ -1120,6 +1128,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "post_v1_processes_run",
"requestBody": { "requestBody": {
"content": { "content": {
@ -1170,6 +1180,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "get_v1_process",
"parameters": [ "parameters": [
{ {
@ -1219,6 +1231,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "delete_v1_process",
"parameters": [ "parameters": [
{ {
@ -1273,6 +1287,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "post_v1_process_input",
"parameters": [ "parameters": [
{ {
@ -1354,6 +1370,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "post_v1_process_kill",
"parameters": [ "parameters": [
{ {
@ -1417,6 +1435,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "get_v1_process_logs",
"parameters": [ "parameters": [
{ {
@ -1515,6 +1535,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "post_v1_process_stop",
"parameters": [ "parameters": [
{ {
@ -1578,6 +1600,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "post_v1_process_terminal_resize",
"parameters": [ "parameters": [
{ {
@ -1659,6 +1683,8 @@
"tags": [ "tags": [
"v1" "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", "operationId": "get_v1_process_terminal_ws",
"parameters": [ "parameters": [
{ {
@ -2013,6 +2039,7 @@
"permission_denied", "permission_denied",
"not_acceptable", "not_acceptable",
"unsupported_media_type", "unsupported_media_type",
"not_found",
"session_not_found", "session_not_found",
"session_already_exists", "session_already_exists",
"mode_not_supported", "mode_not_supported",

View file

@ -115,6 +115,25 @@ await restored.prompt([{ type: "text", text: "Continue from previous context." }
await sdk.destroySession(restored.id); 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 ## Events
Subscribe to live 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 - `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()` - `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
## Types
```ts
import type {
AgentInfo,
HealthResponse,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
```

View file

@ -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: * Usage:
* npx tsx dump.ts # Dump all agents * npx tsx dump.ts # Dump all agents
@ -10,11 +11,24 @@
* Claude Anthropic API (GET /v1/models?beta=true). Extracts API key from * Claude Anthropic API (GET /v1/models?beta=true). Extracts API key from
* ANTHROPIC_API_KEY env. Falls back to aliases (default, sonnet, opus, haiku) * ANTHROPIC_API_KEY env. Falls back to aliases (default, sonnet, opus, haiku)
* on 401/403 or missing credentials. * 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). * 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). * 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. * 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 * 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!. * to the repo and included in the sandbox-agent binary at compile time via include_str!.
*/ */
@ -37,11 +51,19 @@ interface ModeEntry {
description?: string; description?: string;
} }
interface ThoughtLevelEntry {
id: string;
name: string;
description?: string;
}
interface AgentModelList { interface AgentModelList {
defaultModel: string; defaultModel: string;
models: ModelEntry[]; models: ModelEntry[];
defaultMode?: string; defaultMode?: string;
modes?: ModeEntry[]; modes?: ModeEntry[];
defaultThoughtLevel?: string;
thoughtLevels?: ThoughtLevelEntry[];
} }
// ─── CLI ────────────────────────────────────────────────────────────────────── // ─── CLI ──────────────────────────────────────────────────────────────────────
@ -100,8 +122,13 @@ function writeList(agent: string, list: AgentModelList) {
const filePath = path.join(RESOURCES_DIR, `${agent}.json`); const filePath = path.join(RESOURCES_DIR, `${agent}.json`);
fs.writeFileSync(filePath, JSON.stringify(list, null, 2) + "\n"); fs.writeFileSync(filePath, JSON.stringify(list, null, 2) + "\n");
const modeCount = list.modes?.length ?? 0; 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( 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_API_URL = "https://api.anthropic.com/v1/models?beta=true";
const ANTHROPIC_VERSION = "2023-06-01"; 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 = { const CLAUDE_FALLBACK: AgentModelList = {
defaultModel: "default", defaultModel: "default",
models: [ models: [
{ id: "default", name: "Default (recommended)" }, { id: "default", name: "Default" },
{ id: "opus", name: "Opus" },
{ id: "sonnet", name: "Sonnet" }, { id: "sonnet", name: "Sonnet" },
{ id: "opus", name: "Opus" },
{ id: "haiku", name: "Haiku" }, { 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() { async function dumpClaude() {
@ -185,6 +226,9 @@ async function dumpClaude() {
writeList("claude", { writeList("claude", {
defaultModel: defaultModel ?? models[0]?.id ?? "default", defaultModel: defaultModel ?? models[0]?.id ?? "default",
models, 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)); 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", { writeList("codex", {
defaultModel: defaultModel ?? models[0]?.id ?? "", defaultModel: defaultModel ?? models[0]?.id ?? "",
models, 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" },
],
}); });
} }

View file

@ -3,19 +3,42 @@
"models": [ "models": [
{ {
"id": "default", "id": "default",
"name": "Default (recommended)" "name": "Default"
},
{
"id": "opus",
"name": "Opus"
}, },
{ {
"id": "sonnet", "id": "sonnet",
"name": "Sonnet" "name": "Sonnet"
}, },
{
"id": "opus",
"name": "Opus"
},
{ {
"id": "haiku", "id": "haiku",
"name": "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"
}
] ]
} }

View file

@ -2,24 +2,62 @@
"defaultModel": "gpt-5.3-codex", "defaultModel": "gpt-5.3-codex",
"models": [ "models": [
{ {
"id": "gpt-5.1-codex-max", "id": "gpt-5.3-codex",
"name": "gpt-5.1-codex-max" "name": "gpt-5.3-codex"
}, },
{ {
"id": "gpt-5.1-codex-mini", "id": "gpt-5.3-codex-spark",
"name": "gpt-5.1-codex-mini" "name": "GPT-5.3-Codex-Spark"
},
{
"id": "gpt-5.2",
"name": "gpt-5.2"
}, },
{ {
"id": "gpt-5.2-codex", "id": "gpt-5.2-codex",
"name": "gpt-5.2-codex" "name": "gpt-5.2-codex"
}, },
{ {
"id": "gpt-5.3-codex", "id": "gpt-5.1-codex-max",
"name": "gpt-5.3-codex" "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"
} }
] ]
} }

View file

@ -1,5 +1,6 @@
import { import {
AcpHttpClient, AcpHttpClient,
AcpRpcError,
PROTOCOL_VERSION, PROTOCOL_VERSION,
type AcpEnvelopeDirection, type AcpEnvelopeDirection,
type AnyMessage, type AnyMessage,
@ -9,8 +10,12 @@ import {
type NewSessionResponse, type NewSessionResponse,
type PromptRequest, type PromptRequest,
type PromptResponse, type PromptResponse,
type SessionConfigOption,
type SessionNotification, type SessionNotification,
type SessionModeState,
type SetSessionConfigOptionResponse,
type SetSessionConfigOptionRequest, type SetSessionConfigOptionRequest,
type SetSessionModeResponse,
type SetSessionModeRequest, type SetSessionModeRequest,
} from "acp-http-client"; } from "acp-http-client";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts"; 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_EVENTS = 50;
const DEFAULT_REPLAY_MAX_CHARS = 12_000; const DEFAULT_REPLAY_MAX_CHARS = 12_000;
const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500; 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_MIN_DELAY_MS = 500;
const HEALTH_WAIT_MAX_DELAY_MS = 15_000; const HEALTH_WAIT_MAX_DELAY_MS = 15_000;
const HEALTH_WAIT_LOG_AFTER_MS = 5_000; const HEALTH_WAIT_LOG_AFTER_MS = 5_000;
@ -109,12 +117,18 @@ export interface SessionCreateRequest {
id?: string; id?: string;
agent: string; agent: string;
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
thoughtLevel?: string;
} }
export interface SessionResumeOrCreateRequest { export interface SessionResumeOrCreateRequest {
id: string; id: string;
agent: string; agent: string;
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
thoughtLevel?: string;
} }
export interface SessionSendOptions { 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 { export class Session {
private record: SessionRecord; private record: SessionRecord;
private readonly sandbox: SandboxAgent; private readonly sandbox: SandboxAgent;
@ -211,6 +283,38 @@ export class Session {
return response as PromptResponse; return response as PromptResponse;
} }
async setMode(modeId: string): Promise<SetSessionModeResponse | void> {
const updated = await this.sandbox.setSessionMode(this.id, modeId);
this.apply(updated.session.toRecord());
return updated.response;
}
async setConfigOption(configId: string, value: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionConfigOption(this.id, configId, value);
this.apply(updated.session.toRecord());
return updated.response;
}
async setModel(model: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionModel(this.id, model);
this.apply(updated.session.toRecord());
return updated.response;
}
async setThoughtLevel(thoughtLevel: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionThoughtLevel(this.id, thoughtLevel);
this.apply(updated.session.toRecord());
return updated.response;
}
async getConfigOptions(): Promise<SessionConfigOption[]> {
return this.sandbox.getSessionConfigOptions(this.id);
}
async getModes(): Promise<SessionModeState | null> {
return this.sandbox.getSessionModes(this.id);
}
onEvent(listener: SessionEventListener): () => void { onEvent(listener: SessionEventListener): () => void {
return this.sandbox.onSessionEvent(this.id, listener); return this.sandbox.onSessionEvent(this.id, listener);
} }
@ -623,12 +727,35 @@ export class SandboxAgent {
lastConnectionId: live.connectionId, lastConnectionId: live.connectionId,
createdAt: nowMs(), createdAt: nowMs(),
sessionInit, sessionInit,
configOptions: cloneConfigOptions(response.configOptions),
modes: cloneModes(response.modes),
}; };
await this.persist.updateSession(record); await this.persist.updateSession(record);
this.nextSessionEventIndexBySession.set(record.id, 1); this.nextSessionEventIndexBySession.set(record.id, 1);
live.bindSession(record.id, record.agentSessionId); 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<Session> { async resumeSession(id: string): Promise<Session> {
@ -652,6 +779,8 @@ export class SandboxAgent {
agentSessionId: recreated.sessionId, agentSessionId: recreated.sessionId,
lastConnectionId: live.connectionId, lastConnectionId: live.connectionId,
destroyedAt: undefined, destroyedAt: undefined,
configOptions: cloneConfigOptions(recreated.configOptions),
modes: cloneModes(recreated.modes),
}; };
await this.persist.updateSession(updated); await this.persist.updateSession(updated);
@ -664,16 +793,28 @@ export class SandboxAgent {
async resumeOrCreateSession(request: SessionResumeOrCreateRequest): Promise<Session> { async resumeOrCreateSession(request: SessionResumeOrCreateRequest): Promise<Session> {
const existing = await this.persist.getSession(request.id); const existing = await this.persist.getSession(request.id);
if (existing) { 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); return this.createSession(request);
} }
async destroySession(id: string): Promise<Session> { async destroySession(id: string): Promise<Session> {
const existing = await this.persist.getSession(id); try {
if (!existing) { await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
throw new Error(`session '${id}' not found`); } catch {
// Best-effort: agent may already be gone
} }
const existing = await this.requireSessionRecord(id);
const updated: SessionRecord = { const updated: SessionRecord = {
...existing, ...existing,
@ -684,12 +825,181 @@ export class SandboxAgent {
return this.upsertSessionHandle(updated); 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<SessionConfigOption[]> {
const record = await this.requireSessionRecord(sessionId);
const hydrated = await this.hydrateSessionConfigOptions(record.id, record);
return cloneConfigOptions(hydrated.configOptions) ?? [];
}
async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
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<SessionRecord> {
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( async sendSessionMethod(
sessionId: string, sessionId: string,
method: string, method: string,
params: Record<string, unknown>, params: Record<string, unknown>,
options: SessionSendOptions = {}, options: SessionSendOptions = {},
): Promise<{ session: Session; response: unknown }> { ): Promise<{ session: Session; response: unknown }> {
return this.sendSessionMethodInternal(sessionId, method, params, options, false);
}
private async sendSessionMethodInternal(
sessionId: string,
method: string,
params: Record<string, unknown>,
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); const record = await this.persist.getSession(sessionId);
if (!record) { if (!record) {
throw new Error(`session '${sessionId}' not found`); throw new Error(`session '${sessionId}' not found`);
@ -699,10 +1009,11 @@ export class SandboxAgent {
if (!live.hasBoundSession(record.id, record.agentSessionId)) { if (!live.hasBoundSession(record.id, record.agentSessionId)) {
// The persisted session points at a stale connection; restore lazily. // The persisted session points at a stale connection; restore lazily.
const restored = await this.resumeSession(record.id); 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); 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); const refreshed = await this.requireSessionRecord(record.id);
return { return {
session: this.upsertSessionHandle(refreshed), session: this.upsertSessionHandle(refreshed),
@ -710,6 +1021,83 @@ export class SandboxAgent {
}; };
} }
private async persistSessionStateFromMethod(
sessionId: string,
method: string,
params: Record<string, unknown>,
response: unknown,
): Promise<void> {
// 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<SessionRecord> = {};
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<SessionRecord> = {};
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 { onSessionEvent(sessionId: string, listener: SessionEventListener): () => void {
const listeners = this.eventListeners.get(sessionId) ?? new Set<SessionEventListener>(); const listeners = this.eventListeners.get(sessionId) ?? new Set<SessionEventListener>();
listeners.add(listener); listeners.add(listener);
@ -1024,6 +1412,7 @@ export class SandboxAgent {
}; };
await this.persist.insertEvent(event); await this.persist.insertEvent(event);
await this.persistSessionStateFromEvent(localSessionId, envelope, direction);
const listeners = this.eventListeners.get(localSessionId); const listeners = this.eventListeners.get(localSessionId);
if (!listeners || listeners.size === 0) { if (!listeners || listeners.size === 0) {
@ -1035,6 +1424,56 @@ export class SandboxAgent {
} }
} }
private async persistSessionStateFromEvent(
sessionId: string,
envelope: AnyMessage,
direction: AcpEnvelopeDirection,
): Promise<void> {
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<number> { private async allocateSessionEventIndex(sessionId: string): Promise<number> {
await this.ensureSessionEventIndexSeeded(sessionId); await this.ensureSessionEventIndexSeeded(sessionId);
const nextIndex = this.nextSessionEventIndexBySession.get(sessionId) ?? 1; const nextIndex = this.nextSessionEventIndexBySession.get(sessionId) ?? 1;
@ -1543,6 +1982,145 @@ async function readProblem(response: Response): Promise<ProblemDetails | undefin
} }
} }
function normalizeSessionConfigOptions(value: unknown): SessionConfigOption[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized = value.filter(isSessionConfigOption) as SessionConfigOption[];
return cloneConfigOptions(normalized) ?? [];
}
function extractConfigOptionsFromSetResponse(response: unknown): SessionConfigOption[] | undefined {
if (!isRecord(response)) {
return undefined;
}
return normalizeSessionConfigOptions(response.configOptions);
}
function findConfigOptionByCategory(
options: SessionConfigOption[],
category: string,
): SessionConfigOption | undefined {
return options.find((option) => 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<string, unknown> | 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 { function formatHealthWaitError(error: unknown): string {
if (error instanceof Error && error.message) { if (error instanceof Error && error.message) {
return error.message; return error.message;

View file

@ -58,36 +58,105 @@ export interface paths {
get: operations["get_v1_health"]; get: operations["get_v1_health"];
}; };
"/v1/processes": { "/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"]; 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"]; post: operations["post_v1_processes"];
}; };
"/v1/processes/config": { "/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"]; 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"]; post: operations["post_v1_processes_config"];
}; };
"/v1/processes/run": { "/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"]; post: operations["post_v1_processes_run"];
}; };
"/v1/processes/{id}": { "/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"]; 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"]; delete: operations["delete_v1_process"];
}; };
"/v1/processes/{id}/input": { "/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"]; post: operations["post_v1_process_input"];
}; };
"/v1/processes/{id}/kill": { "/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"]; post: operations["post_v1_process_kill"];
}; };
"/v1/processes/{id}/logs": { "/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"]; get: operations["get_v1_process_logs"];
}; };
"/v1/processes/{id}/stop": { "/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"]; post: operations["post_v1_process_stop"];
}; };
"/v1/processes/{id}/terminal/resize": { "/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"]; post: operations["post_v1_process_terminal_resize"];
}; };
"/v1/processes/{id}/terminal/ws": { "/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"]; get: operations["get_v1_process_terminal_ws"];
}; };
} }
@ -166,7 +235,7 @@ export interface components {
agents: components["schemas"]["AgentInfo"][]; agents: components["schemas"]["AgentInfo"][];
}; };
/** @enum {string} */ /** @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: { FsActionResponse: {
path: string; 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: { get_v1_processes: {
responses: { responses: {
/** @description List processes */ /** @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: { post_v1_processes: {
requestBody: { requestBody: {
content: { 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: { get_v1_processes_config: {
responses: { responses: {
/** @description Current runtime process config */ /** @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: { post_v1_processes_config: {
requestBody: { requestBody: {
content: { 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: { post_v1_processes_run: {
requestBody: { requestBody: {
content: { 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: { get_v1_process: {
parameters: { parameters: {
path: { 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: { delete_v1_process: {
parameters: { parameters: {
path: { 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: { post_v1_process_input: {
parameters: { parameters: {
path: { 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: { post_v1_process_kill: {
parameters: { parameters: {
query?: { 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: { get_v1_process_logs: {
parameters: { parameters: {
query?: { 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: { post_v1_process_stop: {
parameters: { parameters: {
query?: { 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: { post_v1_process_terminal_resize: {
parameters: { parameters: {
path: { 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: { get_v1_process_terminal_ws: {
parameters: { parameters: {
query?: { query?: {

View file

@ -3,6 +3,9 @@ export {
SandboxAgent, SandboxAgent,
SandboxAgentError, SandboxAgentError,
Session, Session,
UnsupportedSessionCategoryError,
UnsupportedSessionConfigOptionError,
UnsupportedSessionValueError,
} from "./client.ts"; } from "./client.ts";
export { AcpRpcError } from "acp-http-client"; export { AcpRpcError } from "acp-http-client";

View file

@ -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"; import type { components, operations } from "./generated/openapi.ts";
export type ProblemDetails = components["schemas"]["ProblemDetails"]; export type ProblemDetails = components["schemas"]["ProblemDetails"];
@ -92,6 +97,8 @@ export interface SessionRecord {
createdAt: number; createdAt: number;
destroyedAt?: number; destroyedAt?: number;
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
configOptions?: SessionConfigOption[];
modes?: SessionModeState | null;
} }
export type SessionEventSender = "client" | "agent"; export type SessionEventSender = "client" | "agent";
@ -231,6 +238,12 @@ function cloneSessionRecord(session: SessionRecord): SessionRecord {
sessionInit: session.sessionInit sessionInit: session.sessionInit
? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"]) ? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"])
: undefined, : 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,
}; };
} }

View file

@ -520,6 +520,127 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose(); 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 () => { it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({ const sdk = await SandboxAgent.connect({
baseUrl, baseUrl,

View file

@ -78,7 +78,7 @@ impl AgentId {
fn agent_process_registry_id(self) -> Option<&'static str> { fn agent_process_registry_id(self) -> Option<&'static str> {
match self { match self {
AgentId::Claude => Some("claude-code-acp"), AgentId::Claude => Some("claude-acp"),
AgentId::Codex => Some("codex-acp"), AgentId::Codex => Some("codex-acp"),
AgentId::Opencode => Some("opencode"), AgentId::Opencode => Some("opencode"),
AgentId::Amp => Some("amp-acp"), AgentId::Amp => Some("amp-acp"),
@ -90,7 +90,7 @@ impl AgentId {
fn agent_process_binary_hint(self) -> Option<&'static str> { fn agent_process_binary_hint(self) -> Option<&'static str> {
match self { match self {
AgentId::Claude => Some("claude-code-acp"), AgentId::Claude => Some("claude-agent-acp"),
AgentId::Codex => Some("codex-acp"), AgentId::Codex => Some("codex-acp"),
AgentId::Opencode => Some("opencode"), AgentId::Opencode => Some("opencode"),
AgentId::Amp => Some("amp-acp"), AgentId::Amp => Some("amp-acp"),
@ -606,7 +606,7 @@ impl AgentManager {
match agent { match agent {
AgentId::Claude => { AgentId::Claude => {
let package = fallback_npx_package( let package = fallback_npx_package(
"@zed-industries/claude-code-acp", "@zed-industries/claude-agent-acp",
options.agent_process_version.as_deref(), options.agent_process_version.as_deref(),
); );
write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?;

View file

@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command as ProcessCommand; use std::process::Command as ProcessCommand;
@ -24,7 +24,7 @@ use sandbox_agent_agent_credentials::{
ProviderCredentials, ProviderCredentials,
}; };
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use thiserror::Error; use thiserror::Error;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
@ -220,6 +220,8 @@ pub struct AgentsArgs {
pub enum AgentsCommand { pub enum AgentsCommand {
/// List all agents and install status. /// List all agents and install status.
List(ClientArgs), List(ClientArgs),
/// Emit JSON report of model/mode/thought options for all agents.
Report(ClientArgs),
/// Install or reinstall an agent. /// Install or reinstall an agent.
Install(ApiInstallAgentArgs), 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!({}))?; let result = call_acp_extension(&ctx, ACP_EXTENSION_AGENT_LIST_METHOD, json!({}))?;
write_stdout_line(&serde_json::to_string_pretty(&result)?) write_stdout_line(&serde_json::to_string_pretty(&result)?)
} }
AgentsCommand::Report(args) => run_agents_report(args, cli),
AgentsCommand::Install(args) => { AgentsCommand::Install(args) => {
let ctx = ClientContext::new(cli, &args.client)?; let ctx = ClientContext::new(cli, &args.client)?;
let mut params = serde_json::Map::new(); 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<AgentListApiAgent>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentListApiAgent {
id: String,
installed: bool,
#[serde(default)]
config_error: Option<String>,
#[serde(default)]
config_options: Option<Vec<Value>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawConfigOption {
#[serde(default)]
id: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
current_value: Option<Value>,
#[serde(default)]
options: Vec<RawConfigOptionChoice>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawConfigOptionChoice {
#[serde(default)]
value: Value,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigReport {
generated_at_ms: u128,
endpoint: String,
agents: Vec<AgentConfigReportEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigReportEntry {
id: String,
installed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
config_error: Option<String>,
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<String>,
values: Vec<AgentConfigValueReport>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct AgentConfigValueReport {
value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Clone, Copy)]
enum ConfigReportCategory {
Model,
Mode,
ThoughtLevel,
}
#[derive(Default)]
struct CategoryAccumulator {
current_value: Option<String>,
values: BTreeMap<String, Option<String>>,
}
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::<RawConfigOption>(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<ConfigReportCategory> {
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<String> {
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<Value, CliError> { fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Result<Value, CliError> {
let server_id = unique_cli_server_id("cli-ext"); let server_id = unique_cli_server_id("cli-ext");
let initialize_path = build_acp_server_path(&server_id, Some("mock"))?; let initialize_path = build_acp_server_path(&server_id, Some("mock"))?;
@ -1219,4 +1439,96 @@ mod tests {
.expect("build request"); .expect("build request");
assert!(request.headers().get("last-event-id").is_none()); 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"));
}
} }

View file

@ -147,6 +147,9 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
AgentId::Codex => CODEX.clone(), AgentId::Codex => CODEX.clone(),
AgentId::Opencode => OPENCODE.clone(), AgentId::Opencode => OPENCODE.clone(),
AgentId::Cursor => CURSOR.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![ AgentId::Amp => vec![
json!({ json!({
"id": "model", "id": "model",
@ -163,12 +166,10 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
"name": "Mode", "name": "Mode",
"category": "mode", "category": "mode",
"type": "select", "type": "select",
"currentValue": "smart", "currentValue": "default",
"options": [ "options": [
{ "value": "smart", "name": "Smart" }, { "value": "default", "name": "Default" },
{ "value": "deep", "name": "Deep" }, { "value": "bypass", "name": "Bypass" }
{ "value": "free", "name": "Free" },
{ "value": "rush", "name": "Rush" }
] ]
}), }),
], ],
@ -182,41 +183,76 @@ pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
{ "value": "default", "name": "Default" } { "value": "default", "name": "Default" }
] ]
})], })],
AgentId::Mock => vec![json!({ AgentId::Mock => vec![
"id": "model", json!({
"name": "Model", "id": "model",
"category": "model", "name": "Model",
"type": "select", "category": "model",
"currentValue": "mock", "type": "select",
"options": [ "currentValue": "mock",
{ "value": "mock", "name": "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 /// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into
/// ACP `SessionConfigOption` values. The JSON format is: /// ACP `SessionConfigOption` values. The JSON format is:
/// ```json /// ```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<Value> { fn parse_agent_config(json_str: &str) -> Vec<Value> {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct AgentConfig { struct AgentConfig {
#[serde(rename = "defaultModel")] #[serde(rename = "defaultModel")]
default_model: String, default_model: String,
models: Vec<ModelEntry>, models: Vec<ConfigEntry>,
#[serde(rename = "defaultMode")] #[serde(rename = "defaultMode")]
default_mode: Option<String>, default_mode: Option<String>,
modes: Option<Vec<ModeEntry>>, modes: Option<Vec<ConfigEntry>>,
#[serde(rename = "defaultThoughtLevel")]
default_thought_level: Option<String>,
#[serde(rename = "thoughtLevels")]
thought_levels: Option<Vec<ConfigEntry>>,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct ModelEntry { struct ConfigEntry {
id: String,
name: String,
}
#[derive(serde::Deserialize)]
struct ModeEntry {
id: String, id: String,
name: String, name: String,
} }
@ -242,7 +278,7 @@ fn parse_agent_config(json_str: &str) -> Vec<Value> {
"name": "Mode", "name": "Mode",
"category": "mode", "category": "mode",
"type": "select", "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!({ "options": modes.iter().map(|m| json!({
"value": m.id, "value": m.id,
"name": m.name, "name": m.name,
@ -250,6 +286,20 @@ fn parse_agent_config(json_str: &str) -> Vec<Value> {
})); }));
} }
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::<Vec<_>>(),
}));
}
options options
} }