diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e009cad..476ed12 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,15 +14,16 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@main + - uses: Swatinem/rust-cache@v2 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install + - run: npm install -g tsx - name: Run checks - run: ./scripts/release/main.ts --version 0.0.0 --check + run: ./scripts/release/main.ts --version 0.0.0 --only-steps run-ci-checks - name: Run ACP v1 server tests run: | cargo test -p sandbox-agent-agent-management @@ -31,5 +32,3 @@ jobs: cargo test -p sandbox-agent --lib - name: Run SDK tests run: pnpm --dir sdks/typescript test - - name: Run Inspector browser E2E - run: pnpm --filter @sandbox-agent/inspector test:agent-browser 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/Cargo.toml b/Cargo.toml index 5a0581e..eac5ecc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["server/packages/*", "gigacode"] [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2021" authors = [ "Rivet Gaming, LLC " ] license = "Apache-2.0" @@ -12,13 +12,13 @@ description = "Universal API for automatic coding agents in sandboxes. Supports [workspace.dependencies] # Internal crates -sandbox-agent = { version = "0.2.1", path = "server/packages/sandbox-agent" } -sandbox-agent-error = { version = "0.2.1", path = "server/packages/error" } -sandbox-agent-agent-management = { version = "0.2.1", path = "server/packages/agent-management" } -sandbox-agent-agent-credentials = { version = "0.2.1", path = "server/packages/agent-credentials" } -sandbox-agent-opencode-adapter = { version = "0.2.1", path = "server/packages/opencode-adapter" } -sandbox-agent-opencode-server-manager = { version = "0.2.1", path = "server/packages/opencode-server-manager" } -acp-http-adapter = { version = "0.2.1", path = "server/packages/acp-http-adapter" } +sandbox-agent = { version = "0.2.2", path = "server/packages/sandbox-agent" } +sandbox-agent-error = { version = "0.2.2", path = "server/packages/error" } +sandbox-agent-agent-management = { version = "0.2.2", path = "server/packages/agent-management" } +sandbox-agent-agent-credentials = { version = "0.2.2", path = "server/packages/agent-credentials" } +sandbox-agent-opencode-adapter = { version = "0.2.2", path = "server/packages/opencode-adapter" } +sandbox-agent-opencode-server-manager = { version = "0.2.2", path = "server/packages/opencode-server-manager" } +acp-http-adapter = { version = "0.2.2", path = "server/packages/acp-http-adapter" } # Serialization serde = { version = "1.0", features = ["derive"] } 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/deploy/docker.mdx b/docs/deploy/docker.mdx index 28c9f77..1c64af8 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -16,17 +16,11 @@ docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ alpine:latest sh -c "\ - apk add --no-cache curl ca-certificates libstdc++ libgcc bash && \ + apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \ curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \ - sandbox-agent install-agent claude && \ - sandbox-agent install-agent codex && \ sandbox-agent server --no-token --host 0.0.0.0 --port 3000" ``` - -Alpine is required for some agent binaries that target musl libc. - - ## TypeScript with dockerode ```typescript @@ -37,17 +31,18 @@ const docker = new Docker(); const PORT = 3000; const container = await docker.createContainer({ - Image: "alpine:latest", + Image: "node:22-bookworm-slim", Cmd: ["sh", "-c", [ - "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", + "apt-get update", + "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", + "rm -rf /var/lib/apt/lists/*", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", - "sandbox-agent install-agent claude", - "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, ].join(" && ")], Env: [ `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, + `CODEX_API_KEY=${process.env.CODEX_API_KEY}`, ].filter(Boolean), ExposedPorts: { [`${PORT}/tcp`]: {} }, HostConfig: { @@ -61,7 +56,7 @@ await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; const sdk = await SandboxAgent.connect({ baseUrl }); -const session = await sdk.createSession({ agent: "claude" }); +const session = await sdk.createSession({ agent: "codex" }); await session.prompt([{ type: "text", text: "Summarize this repository." }]); ``` diff --git a/docs/docs.json b/docs/docs.json index afccb80..b4d1a82 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 383fc31..d6272b7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1595,13 +1595,96 @@ } } }, + "/v1/processes/{id}/terminal/resize": { + "post": { + "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": [ + { + "name": "id", + "in": "path", + "description": "Process ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcessTerminalResizeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Resize accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcessTerminalResizeResponse" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Unknown process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Not a terminal process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "501": { + "description": "Process API unsupported on this platform", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/processes/{id}/terminal/ws": { "get": { "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). Uses the `channel.k8s.io` binary subprotocol:\nchannel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize,\nand channel 255 close.", + "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": [ { @@ -2674,6 +2757,44 @@ "exited" ] }, + "ProcessTerminalResizeRequest": { + "type": "object", + "required": [ + "cols", + "rows" + ], + "properties": { + "cols": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "rows": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "ProcessTerminalResizeResponse": { + "type": "object", + "required": [ + "cols", + "rows" + ], + "properties": { + "cols": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "rows": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "ServerStatus": { "type": "string", "enum": [ diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index 7974c65..ffb1a6b 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -39,6 +39,8 @@ const sdk = await SandboxAgent.connect({ }); ``` +`SandboxAgent.connect(...)` now waits for `/v1/health` by default before other SDK requests proceed. To disable that gate, pass `waitForHealth: false`. To keep the default gate but fail after a bounded wait, pass `waitForHealth: { timeoutMs: 120_000 }`. To cancel the startup wait early, pass `signal: abortController.signal`. + With a custom fetch handler (for example, proxying requests inside Workers): ```ts @@ -47,6 +49,19 @@ const sdk = await SandboxAgent.connect({ }); ``` +With an abort signal for the startup health gate: + +```ts +const controller = new AbortController(); + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + signal: controller.signal, +}); + +controller.abort(); +``` + With persistence: ```ts @@ -100,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: @@ -170,14 +204,6 @@ Parameters: - `token` (optional): Bearer token for authenticated servers - `headers` (optional): Additional request headers - `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls +- `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/examples/boxlite/src/index.ts b/examples/boxlite/src/index.ts index e5ce412..c2401be 100644 --- a/examples/boxlite/src/index.ts +++ b/examples/boxlite/src/index.ts @@ -1,6 +1,6 @@ import { SimpleBox } from "@boxlite-ai/boxlite"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { setupImage, OCI_DIR } from "./setup-image.ts"; const env: Record = {}; @@ -26,9 +26,7 @@ if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.std const baseUrl = "http://localhost:3000"; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/computesdk/src/computesdk.ts b/examples/computesdk/src/computesdk.ts index bc2ddc6..37f413d 100644 --- a/examples/computesdk/src/computesdk.ts +++ b/examples/computesdk/src/computesdk.ts @@ -10,7 +10,7 @@ import { type ProviderName, } from "computesdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; @@ -116,9 +116,6 @@ export async function setupComputeSdkSandboxAgent(): Promise<{ const baseUrl = await sandbox.getUrl({ port: PORT }); - console.log("Waiting for server..."); - await waitForHealth({ baseUrl }); - const cleanup = async () => { try { await sandbox.destroy(); diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts index 0ec694d..d6900df 100644 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ b/examples/daytona/src/daytona-with-snapshot.ts @@ -1,6 +1,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -25,9 +25,7 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index ddbd6fb..bbf9d6e 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,6 +1,6 @@ import { Daytona } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -30,9 +30,7 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index e31d8ed..593ef31 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -1,9 +1,16 @@ import Docker from "dockerode"; +import fs from "node:fs"; +import path from "node:path"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; -const IMAGE = "alpine:latest"; +const IMAGE = "node:22-bookworm-slim"; const PORT = 3000; +const agent = detectAgent(); +const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; +const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) + ? [`${codexAuthPath}:/root/.codex/auth.json:ro`] + : []; const docker = new Docker({ socketPath: "/var/run/docker.sock" }); @@ -24,29 +31,30 @@ console.log("Starting container..."); const container = await docker.createContainer({ Image: IMAGE, Cmd: ["sh", "-c", [ - "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", + "apt-get update", + "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", + "rm -rf /var/lib/apt/lists/*", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", - "sandbox-agent install-agent claude", - "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, ].join(" && ")], Env: [ process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", + process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "", ].filter(Boolean), ExposedPorts: { [`${PORT}/tcp`]: {} }, HostConfig: { AutoRemove: true, PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, + Binds: bindMounts, }, }); await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; -await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index 48fcc01..b02f239 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -27,9 +27,7 @@ await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --por const baseUrl = `https://${sandbox.getHost(3000)}`; -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts index 5ec8a8c..adceecb 100644 --- a/examples/shared/src/docker.ts +++ b/examples/shared/src/docker.ts @@ -4,7 +4,6 @@ import fs from "node:fs"; import path from "node:path"; import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; -import { waitForHealth } from "./sandbox-agent-client.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const EXAMPLE_IMAGE = "sandbox-agent-examples:latest"; @@ -173,7 +172,7 @@ async function ensureExampleImage(_docker: Docker): Promise { } /** - * Start a Docker container running sandbox-agent and wait for it to be healthy. + * Start a Docker container running sandbox-agent. * Registers SIGINT/SIGTERM handlers for cleanup. */ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { @@ -275,18 +274,8 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { stopStartupLogs(); diff --git a/examples/shared/src/sandbox-agent-client.ts b/examples/shared/src/sandbox-agent-client.ts index df8fa51..5c7e7cf 100644 --- a/examples/shared/src/sandbox-agent-client.ts +++ b/examples/shared/src/sandbox-agent-client.ts @@ -3,8 +3,6 @@ * Provides minimal helpers for connecting to and interacting with sandbox-agent servers. */ -import { setTimeout as delay } from "node:timers/promises"; - function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); } @@ -74,41 +72,6 @@ export function buildHeaders({ return headers; } -export async function waitForHealth({ - baseUrl, - token, - extraHeaders, - timeoutMs = 120_000, -}: { - baseUrl: string; - token?: string; - extraHeaders?: Record; - timeoutMs?: number; -}): Promise { - const normalized = normalizeBaseUrl(baseUrl); - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - const headers = buildHeaders({ token, extraHeaders }); - const response = await fetch(`${normalized}/v1/health`, { headers }); - if (response.ok) { - const data = await response.json(); - if (data?.status === "ok") { - return; - } - lastError = new Error(`Unexpected health response: ${JSON.stringify(data)}`); - } else { - lastError = new Error(`Health check failed: ${response.status}`); - } - } catch (error) { - lastError = error; - } - await delay(500); - } - throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error; -} - export function generateSessionId(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = "session-"; @@ -144,4 +107,3 @@ export function detectAgent(): string { } return "claude"; } - diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 56fbfe8..258fbe4 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -38,9 +38,7 @@ await sandbox.runCommand({ const baseUrl = sandbox.domain(3000); -console.log("Waiting for server..."); -await waitForHealth({ baseUrl }); - +console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } }); const sessionId = session.id; diff --git a/gigacode/src/main.rs b/gigacode/src/main.rs index 6710c17..f5e1775 100644 --- a/gigacode/src/main.rs +++ b/gigacode/src/main.rs @@ -11,6 +11,7 @@ fn main() { } fn run() -> Result<(), CliError> { + let started = std::time::Instant::now(); let cli = GigacodeCli::parse(); let config = CliConfig { token: cli.token, @@ -34,5 +35,18 @@ fn run() -> Result<(), CliError> { eprintln!("failed to init logging: {err}"); return Err(err); } - run_command(&command, &config) + tracing::info!( + command = ?command, + startup_ms = started.elapsed().as_millis() as u64, + "gigacode.run: command starting" + ); + let command_started = std::time::Instant::now(); + let result = run_command(&command, &config); + tracing::info!( + command = ?command, + command_ms = command_started.elapsed().as_millis() as u64, + total_ms = started.elapsed().as_millis() as u64, + "gigacode.run: command exited" + ); + result } 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/acp-http-client/package.json b/sdks/acp-http-client/package.json index 1fd4fb6..e81c406 100644 --- a/sdks/acp-http-client/package.json +++ b/sdks/acp-http-client/package.json @@ -1,6 +1,6 @@ { "name": "acp-http-client", - "version": "0.2.1", + "version": "0.2.2", "description": "Protocol-faithful ACP JSON-RPC over streamable HTTP client.", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli-shared/package.json b/sdks/cli-shared/package.json index d878151..3f45e95 100644 --- a/sdks/cli-shared/package.json +++ b/sdks/cli-shared/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-shared", - "version": "0.2.1", + "version": "0.2.2", "description": "Shared helpers for sandbox-agent CLI and SDK", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/package.json b/sdks/cli/package.json index e79e1cc..2afd1c7 100644 --- a/sdks/cli/package.json +++ b/sdks/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli", - "version": "0.2.1", + "version": "0.2.2", "description": "CLI for sandbox-agent - run AI coding agents in sandboxes", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/darwin-arm64/package.json b/sdks/cli/platforms/darwin-arm64/package.json index 74deca6..848650f 100644 --- a/sdks/cli/platforms/darwin-arm64/package.json +++ b/sdks/cli/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-darwin-arm64", - "version": "0.2.1", + "version": "0.2.2", "description": "sandbox-agent CLI binary for macOS ARM64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/darwin-x64/package.json b/sdks/cli/platforms/darwin-x64/package.json index d179804..7d6d0f1 100644 --- a/sdks/cli/platforms/darwin-x64/package.json +++ b/sdks/cli/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-darwin-x64", - "version": "0.2.1", + "version": "0.2.2", "description": "sandbox-agent CLI binary for macOS x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/linux-arm64/package.json b/sdks/cli/platforms/linux-arm64/package.json index 684a4da..cb3e820 100644 --- a/sdks/cli/platforms/linux-arm64/package.json +++ b/sdks/cli/platforms/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-linux-arm64", - "version": "0.2.1", + "version": "0.2.2", "description": "sandbox-agent CLI binary for Linux arm64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/linux-x64/package.json b/sdks/cli/platforms/linux-x64/package.json index 7c004fc..2e85819 100644 --- a/sdks/cli/platforms/linux-x64/package.json +++ b/sdks/cli/platforms/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-linux-x64", - "version": "0.2.1", + "version": "0.2.2", "description": "sandbox-agent CLI binary for Linux x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/win32-x64/package.json b/sdks/cli/platforms/win32-x64/package.json index 9166aee..a454722 100644 --- a/sdks/cli/platforms/win32-x64/package.json +++ b/sdks/cli/platforms/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-win32-x64", - "version": "0.2.1", + "version": "0.2.2", "description": "sandbox-agent CLI binary for Windows x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/package.json b/sdks/gigacode/package.json index 3c9c614..c3bd4c8 100644 --- a/sdks/gigacode/package.json +++ b/sdks/gigacode/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode", - "version": "0.2.1", + "version": "0.2.2", "description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/darwin-arm64/package.json b/sdks/gigacode/platforms/darwin-arm64/package.json index ed7aea7..6e379a0 100644 --- a/sdks/gigacode/platforms/darwin-arm64/package.json +++ b/sdks/gigacode/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-darwin-arm64", - "version": "0.2.1", + "version": "0.2.2", "description": "gigacode CLI binary for macOS arm64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/darwin-x64/package.json b/sdks/gigacode/platforms/darwin-x64/package.json index 2a14864..777ec37 100644 --- a/sdks/gigacode/platforms/darwin-x64/package.json +++ b/sdks/gigacode/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-darwin-x64", - "version": "0.2.1", + "version": "0.2.2", "description": "gigacode CLI binary for macOS x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/linux-arm64/package.json b/sdks/gigacode/platforms/linux-arm64/package.json index a97c711..ce97a5c 100644 --- a/sdks/gigacode/platforms/linux-arm64/package.json +++ b/sdks/gigacode/platforms/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-linux-arm64", - "version": "0.2.1", + "version": "0.2.2", "description": "gigacode CLI binary for Linux arm64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/linux-x64/package.json b/sdks/gigacode/platforms/linux-x64/package.json index 3ba6cd1..61c5805 100644 --- a/sdks/gigacode/platforms/linux-x64/package.json +++ b/sdks/gigacode/platforms/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-linux-x64", - "version": "0.2.1", + "version": "0.2.2", "description": "gigacode CLI binary for Linux x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/win32-x64/package.json b/sdks/gigacode/platforms/win32-x64/package.json index 1e48389..c9de4aa 100644 --- a/sdks/gigacode/platforms/win32-x64/package.json +++ b/sdks/gigacode/platforms/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-win32-x64", - "version": "0.2.1", + "version": "0.2.2", "description": "gigacode CLI binary for Windows x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/persist-indexeddb/package.json b/sdks/persist-indexeddb/package.json index ba6706d..259b61f 100644 --- a/sdks/persist-indexeddb/package.json +++ b/sdks/persist-indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/persist-indexeddb", - "version": "0.2.1", + "version": "0.2.2", "description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK", "license": "Apache-2.0", "repository": { diff --git a/sdks/persist-postgres/package.json b/sdks/persist-postgres/package.json index 245d820..250a198 100644 --- a/sdks/persist-postgres/package.json +++ b/sdks/persist-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/persist-postgres", - "version": "0.2.1", + "version": "0.2.2", "description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK", "license": "Apache-2.0", "repository": { diff --git a/sdks/persist-rivet/package.json b/sdks/persist-rivet/package.json index 4eb1791..fae7e7b 100644 --- a/sdks/persist-rivet/package.json +++ b/sdks/persist-rivet/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/persist-rivet", - "version": "0.2.1", + "version": "0.2.2", "description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK", "license": "Apache-2.0", "repository": { diff --git a/sdks/persist-sqlite/package.json b/sdks/persist-sqlite/package.json index 80623a1..ef73fff 100644 --- a/sdks/persist-sqlite/package.json +++ b/sdks/persist-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/persist-sqlite", - "version": "0.2.1", + "version": "0.2.2", "description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK", "license": "Apache-2.0", "repository": { diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index b9f3716..428ea0b 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "sandbox-agent", - "version": "0.2.1", + "version": "0.2.2", "description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.", "license": "Apache-2.0", "repository": { diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 2a3551d..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"; @@ -51,17 +56,13 @@ import { type ProcessRunRequest, type ProcessRunResponse, type ProcessSignalQuery, + type ProcessTerminalResizeRequest, + type ProcessTerminalResizeResponse, type SessionEvent, type SessionPersistDriver, type SessionRecord, type SkillsConfig, type SkillsConfigQuery, - TerminalChannel, - type TerminalErrorStatus, - type TerminalExitStatus, - type TerminalReadyStatus, - type TerminalResizePayload, - type TerminalStatusMessage, } from "./types.ts"; const API_PREFIX = "/v1"; @@ -71,13 +72,26 @@ 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; +const HEALTH_WAIT_LOG_EVERY_MS = 10_000; + +export interface SandboxAgentHealthWaitOptions { + timeoutMs?: number; +} interface SandboxAgentConnectCommonOptions { headers?: HeadersInit; persist?: SessionPersistDriver; replayMaxEvents?: number; replayMaxChars?: number; + signal?: AbortSignal; token?: string; + waitForHealth?: boolean | SandboxAgentHealthWaitOptions; } export type SandboxAgentConnectOptions = @@ -103,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 { @@ -138,8 +158,6 @@ export interface ProcessTerminalConnectOptions extends ProcessTerminalWebSocketU WebSocket?: typeof WebSocket; } -export type ProcessTerminalSessionOptions = ProcessTerminalConnectOptions; - export class SandboxAgentError extends Error { readonly status: number; readonly problem?: ProblemDetails; @@ -154,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; @@ -207,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); } @@ -478,199 +586,22 @@ export class LiveAcpConnection { } } -export class ProcessTerminalSession { - readonly socket: WebSocket; - readonly closed: Promise; - - private readonly readyListeners = new Set<(status: TerminalReadyStatus) => void>(); - private readonly dataListeners = new Set<(data: Uint8Array) => void>(); - private readonly exitListeners = new Set<(status: TerminalExitStatus) => void>(); - private readonly errorListeners = new Set<(error: TerminalErrorStatus | Error) => void>(); - private readonly closeListeners = new Set<() => void>(); - private readonly textEncoder = new TextEncoder(); - - private closeSignalSent = false; - private closedResolve!: () => void; - - constructor(socket: WebSocket) { - this.socket = socket; - this.socket.binaryType = "arraybuffer"; - this.closed = new Promise((resolve) => { - this.closedResolve = resolve; - }); - - this.socket.addEventListener("message", (event) => { - void this.handleMessage(event.data); - }); - this.socket.addEventListener("error", () => { - this.emitError(new Error("Terminal websocket connection failed.")); - }); - this.socket.addEventListener("close", () => { - this.closedResolve(); - for (const listener of this.closeListeners) { - listener(); - } - }); - } - - onReady(listener: (status: TerminalReadyStatus) => void): () => void { - this.readyListeners.add(listener); - return () => { - this.readyListeners.delete(listener); - }; - } - - onData(listener: (data: Uint8Array) => void): () => void { - this.dataListeners.add(listener); - return () => { - this.dataListeners.delete(listener); - }; - } - - onExit(listener: (status: TerminalExitStatus) => void): () => void { - this.exitListeners.add(listener); - return () => { - this.exitListeners.delete(listener); - }; - } - - onError(listener: (error: TerminalErrorStatus | Error) => void): () => void { - this.errorListeners.add(listener); - return () => { - this.errorListeners.delete(listener); - }; - } - - onClose(listener: () => void): () => void { - this.closeListeners.add(listener); - return () => { - this.closeListeners.delete(listener); - }; - } - - sendInput(data: string | ArrayBuffer | ArrayBufferView): void { - this.sendChannel(TerminalChannel.stdin, encodeTerminalBytes(data)); - } - - resize(payload: TerminalResizePayload): void { - this.sendChannel( - TerminalChannel.resize, - this.textEncoder.encode(JSON.stringify(payload)), - ); - } - - close(): void { - if (this.socket.readyState === WebSocket.CONNECTING) { - this.socket.addEventListener( - "open", - () => { - this.close(); - }, - { once: true }, - ); - return; - } - - if (this.socket.readyState === WebSocket.OPEN) { - if (!this.closeSignalSent) { - this.closeSignalSent = true; - this.sendChannel(TerminalChannel.close, new Uint8Array()); - } - this.socket.close(); - return; - } - - if (this.socket.readyState !== WebSocket.CLOSED) { - this.socket.close(); - } - } - - private async handleMessage(data: unknown): Promise { - try { - const bytes = await decodeTerminalBytes(data); - if (bytes.length === 0) { - this.emitError(new Error("Received terminal frame without a channel byte.")); - return; - } - - const channel = bytes[0]; - const payload = bytes.subarray(1); - - if (channel === TerminalChannel.stdout || channel === TerminalChannel.stderr) { - for (const listener of this.dataListeners) { - listener(payload); - } - return; - } - - if (channel === TerminalChannel.status) { - const text = new TextDecoder().decode(payload); - const parsed = JSON.parse(text) as unknown; - if (!isTerminalStatusMessage(parsed)) { - this.emitError(new Error("Received invalid terminal status payload.")); - return; - } - - if (parsed.type === "ready") { - for (const listener of this.readyListeners) { - listener(parsed); - } - return; - } - - if (parsed.type === "exit") { - for (const listener of this.exitListeners) { - listener(parsed); - } - return; - } - - this.emitError(parsed); - return; - } - - if (channel === TerminalChannel.close) { - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.close(); - } - return; - } - - this.emitError(new Error(`Received unsupported terminal channel ${channel}.`)); - } catch (error) { - this.emitError(error instanceof Error ? error : new Error(String(error))); - } - } - - private sendChannel(channel: number, payload: Uint8Array): void { - if (this.socket.readyState !== WebSocket.OPEN) { - return; - } - - const frame = new Uint8Array(payload.length + 1); - frame[0] = channel; - frame.set(payload, 1); - this.socket.send(frame); - } - - private emitError(error: TerminalErrorStatus | Error): void { - for (const listener of this.errorListeners) { - listener(error); - } - } -} - export class SandboxAgent { private readonly baseUrl: string; private readonly token?: string; private readonly fetcher: typeof fetch; private readonly defaultHeaders?: HeadersInit; + private readonly healthWait: NormalizedHealthWaitOptions; + private readonly healthWaitAbortController = new AbortController(); private readonly persist: SessionPersistDriver; private readonly replayMaxEvents: number; private readonly replayMaxChars: number; private spawnHandle?: SandboxAgentSpawnHandle; + private healthPromise?: Promise; + private healthError?: Error; + private disposed = false; private readonly liveConnections = new Map(); private readonly pendingLiveConnections = new Map>(); @@ -692,10 +623,13 @@ export class SandboxAgent { } this.fetcher = resolvedFetch; this.defaultHeaders = options.headers; + this.healthWait = normalizeHealthWaitOptions(options.waitForHealth, options.signal); this.persist = options.persist ?? new InMemorySessionPersistDriver(); this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS); this.replayMaxChars = normalizePositiveInt(options.replayMaxChars, DEFAULT_REPLAY_MAX_CHARS); + + this.startHealthWait(); } static async connect(options: SandboxAgentConnectOptions): Promise { @@ -717,6 +651,7 @@ export class SandboxAgent { token: handle.token, fetch: options.fetch, headers: options.headers, + waitForHealth: false, persist: options.persist, replayMaxEvents: options.replayMaxEvents, replayMaxChars: options.replayMaxChars, @@ -727,6 +662,9 @@ export class SandboxAgent { } async dispose(): Promise { + this.disposed = true; + this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed.")); + const connections = [...this.liveConnections.values()]; this.liveConnections.clear(); const pending = [...this.pendingLiveConnections.values()]; @@ -789,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 { @@ -818,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); @@ -830,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, @@ -850,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`); @@ -865,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), @@ -876,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); @@ -894,7 +1116,7 @@ export class SandboxAgent { } async getHealth(): Promise { - return this.requestJson("GET", `${API_PREFIX}/health`); + return this.requestHealth(); } async listAgents(options?: AgentQueryOptions): Promise { @@ -1081,6 +1303,19 @@ export class SandboxAgent { }); } + async resizeProcessTerminal( + id: string, + request: ProcessTerminalResizeRequest, + ): Promise { + return this.requestJson( + "POST", + `${API_PREFIX}/processes/${encodeURIComponent(id)}/terminal/resize`, + { + body: request, + }, + ); + } + buildProcessTerminalWebSocketUrl( id: string, options: ProcessTerminalWebSocketUrlOptions = {}, @@ -1105,18 +1340,13 @@ export class SandboxAgent { this.buildProcessTerminalWebSocketUrl(id, { accessToken: options.accessToken, }), - options.protocols ?? "channel.k8s.io", + options.protocols, ); } - connectProcessTerminal( - id: string, - options: ProcessTerminalSessionOptions = {}, - ): ProcessTerminalSession { - return new ProcessTerminalSession(this.connectProcessTerminalWebSocket(id, options)); - } - private async getLiveConnection(agent: string): Promise { + await this.awaitHealthy(); + const existing = this.liveConnections.get(agent); if (existing) { return existing; @@ -1182,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) { @@ -1193,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; @@ -1297,6 +1578,7 @@ export class SandboxAgent { headers: options.headers, accept: options.accept ?? "application/json", signal: options.signal, + skipReadyWait: options.skipReadyWait, }); if (response.status === 204) { @@ -1307,6 +1589,10 @@ export class SandboxAgent { } private async requestRaw(method: string, path: string, options: RequestOptions = {}): Promise { + if (!options.skipReadyWait) { + await this.awaitHealthy(options.signal); + } + const url = this.buildUrl(path, options.query); const headers = this.buildHeaders(options.headers); @@ -1343,6 +1629,79 @@ export class SandboxAgent { return response; } + private startHealthWait(): void { + if (!this.healthWait.enabled || this.healthPromise) { + return; + } + + this.healthPromise = this.runHealthWait().catch((error) => { + this.healthError = error instanceof Error ? error : new Error(String(error)); + }); + } + + private async awaitHealthy(signal?: AbortSignal): Promise { + if (!this.healthPromise) { + throwIfAborted(signal); + return; + } + + await waitForAbortable(this.healthPromise, signal); + throwIfAborted(signal); + if (this.healthError) { + throw this.healthError; + } + } + + private async runHealthWait(): Promise { + const signal = this.healthWait.enabled + ? anyAbortSignal([this.healthWait.signal, this.healthWaitAbortController.signal]) + : undefined; + const startedAt = Date.now(); + const deadline = + typeof this.healthWait.timeoutMs === "number" ? startedAt + this.healthWait.timeoutMs : undefined; + + let delayMs = HEALTH_WAIT_MIN_DELAY_MS; + let nextLogAt = startedAt + HEALTH_WAIT_LOG_AFTER_MS; + let lastError: unknown; + + while (!this.disposed && (deadline === undefined || Date.now() < deadline)) { + throwIfAborted(signal); + + try { + const health = await this.requestHealth({ signal }); + if (health.status === "ok") { + return; + } + lastError = new Error(`Unexpected health response: ${JSON.stringify(health)}`); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + lastError = error; + } + + const now = Date.now(); + if (now >= nextLogAt) { + const details = formatHealthWaitError(lastError); + console.warn( + `sandbox-agent at ${this.baseUrl} is not healthy after ${now - startedAt}ms; still waiting (${details})`, + ); + nextLogAt = now + HEALTH_WAIT_LOG_EVERY_MS; + } + + await sleep(delayMs, signal); + delayMs = Math.min(HEALTH_WAIT_MAX_DELAY_MS, delayMs * 2); + } + + if (this.disposed) { + return; + } + + throw new Error( + `Timed out waiting for sandbox-agent health after ${this.healthWait.timeoutMs}ms (${formatHealthWaitError(lastError)})`, + ); + } + private buildHeaders(extra?: HeadersInit): Headers { const headers = new Headers(this.defaultHeaders ?? undefined); @@ -1372,6 +1731,13 @@ export class SandboxAgent { return url.toString(); } + + private async requestHealth(options: { signal?: AbortSignal } = {}): Promise { + return this.requestJson("GET", `${API_PREFIX}/health`, { + signal: options.signal, + skipReadyWait: true, + }); + } } type QueryValue = string | number | boolean | null | undefined; @@ -1384,63 +1750,12 @@ type RequestOptions = { headers?: HeadersInit; accept?: string; signal?: AbortSignal; + skipReadyWait?: boolean; }; -function isTerminalStatusMessage(value: unknown): value is TerminalStatusMessage { - if (!isRecord(value) || typeof value.type !== "string") { - return false; - } - - if (value.type === "ready") { - return typeof value.processId === "string"; - } - - if (value.type === "exit") { - return ( - value.exitCode === undefined || - value.exitCode === null || - typeof value.exitCode === "number" - ); - } - - if (value.type === "error") { - return typeof value.message === "string"; - } - - return false; -} - -function encodeTerminalBytes(data: string | ArrayBuffer | ArrayBufferView): Uint8Array { - if (typeof data === "string") { - return new TextEncoder().encode(data); - } - - if (data instanceof ArrayBuffer) { - return new Uint8Array(data); - } - - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); -} - -async function decodeTerminalBytes(data: unknown): Promise { - if (data instanceof ArrayBuffer) { - return new Uint8Array(data); - } - - if (ArrayBuffer.isView(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); - } - - if (typeof Blob !== "undefined" && data instanceof Blob) { - return new Uint8Array(await data.arrayBuffer()); - } - - if (typeof data === "string") { - throw new Error("Received text terminal frame; expected channel.k8s.io binary data."); - } - - throw new Error(`Unsupported terminal frame payload: ${String(data)}`); -} +type NormalizedHealthWaitOptions = + | { enabled: false; timeoutMs?: undefined; signal?: undefined } + | { enabled: true; timeoutMs?: number; signal?: AbortSignal }; /** * Auto-select and call `authenticate` based on the agent's advertised auth methods. @@ -1613,6 +1928,30 @@ function normalizePositiveInt(value: number | undefined, fallback: number): numb return Math.floor(value as number); } +function normalizeHealthWaitOptions( + value: boolean | SandboxAgentHealthWaitOptions | undefined, + signal: AbortSignal | undefined, +): NormalizedHealthWaitOptions { + if (value === false) { + return { enabled: false }; + } + + if (value === true || value === undefined) { + return { enabled: true, signal }; + } + + const timeoutMs = + typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0 + ? Math.floor(value.timeoutMs) + : undefined; + + return { + enabled: true, + signal, + timeoutMs, + }; +} + function normalizeSpawnOptions( spawn: SandboxAgentSpawnOptions | boolean | undefined, defaultEnabled: boolean, @@ -1643,6 +1982,231 @@ 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; + } + + if (error === undefined || error === null) { + return "unknown error"; + } + + return String(error); +} + +function anyAbortSignal(signals: Array): AbortSignal | undefined { + const active = signals.filter((signal): signal is AbortSignal => Boolean(signal)); + if (active.length === 0) { + return undefined; + } + + if (active.length === 1) { + return active[0]; + } + + const controller = new AbortController(); + const onAbort = (event: Event) => { + cleanup(); + const signal = event.target as AbortSignal; + controller.abort(signal.reason ?? createAbortError()); + }; + const cleanup = () => { + for (const signal of active) { + signal.removeEventListener("abort", onAbort); + } + }; + + for (const signal of active) { + if (signal.aborted) { + controller.abort(signal.reason ?? createAbortError()); + return controller.signal; + } + } + + for (const signal of active) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + return controller.signal; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) { + return; + } + + throw signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason); +} + +async function waitForAbortable(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) { + return promise; + } + + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const onAbort = () => { + cleanup(); + reject(signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason)); + }; + const cleanup = () => { + signal.removeEventListener("abort", onAbort); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + async function consumeProcessLogSse( body: ReadableStream, listener: ProcessLogListener, @@ -1732,3 +2296,43 @@ function toWebSocketUrl(url: string): string { function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } + +function createAbortError(reason?: unknown): Error { + if (reason instanceof Error) { + return reason; + } + + const message = typeof reason === "string" ? reason : "This operation was aborted."; + if (typeof DOMException !== "undefined") { + return new DOMException(message, "AbortError"); + } + + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = () => { + cleanup(); + reject(signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason)); + }; + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 191f0d3..18374fb 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -141,14 +141,21 @@ export interface paths { */ 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). Uses the `channel.k8s.io` binary subprotocol: - * channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize, - * and channel 255 close. + * 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"]; }; @@ -423,6 +430,18 @@ export interface components { }; /** @enum {string} */ ProcessState: "running" | "exited"; + ProcessTerminalResizeRequest: { + /** Format: int32 */ + cols: number; + /** Format: int32 */ + rows: number; + }; + ProcessTerminalResizeResponse: { + /** Format: int32 */ + cols: number; + /** Format: int32 */ + rows: number; + }; /** @enum {string} */ ServerStatus: "running" | "stopped"; ServerStatusInfo: { @@ -1324,13 +1343,62 @@ 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: { + /** @description Process ID */ + id: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProcessTerminalResizeRequest"]; + }; + }; + responses: { + /** @description Resize accepted */ + 200: { + content: { + "application/json": components["schemas"]["ProcessTerminalResizeResponse"]; + }; + }; + /** @description Invalid request */ + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Unknown process */ + 404: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Not a terminal process */ + 409: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Process API unsupported on this platform */ + 501: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; /** * 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). Uses the `channel.k8s.io` binary subprotocol: - * channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize, - * and channel 255 close. + * 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: { diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index e9e6f7e..cf25645 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,9 +1,11 @@ export { LiveAcpConnection, - ProcessTerminalSession, SandboxAgent, SandboxAgentError, Session, + UnsupportedSessionCategoryError, + UnsupportedSessionConfigOptionError, + UnsupportedSessionValueError, } from "./client.ts"; export { AcpRpcError } from "acp-http-client"; @@ -11,12 +13,12 @@ export { AcpRpcError } from "acp-http-client"; export { buildInspectorUrl } from "./inspector.ts"; export type { + SandboxAgentHealthWaitOptions, AgentQueryOptions, ProcessLogFollowQuery, ProcessLogListener, ProcessLogSubscription, ProcessTerminalConnectOptions, - ProcessTerminalSessionOptions, ProcessTerminalWebSocketUrlOptions, SandboxAgentConnectOptions, SandboxAgentStartOptions, @@ -30,7 +32,6 @@ export type { InspectorUrlOptions } from "./inspector.ts"; export { InMemorySessionPersistDriver, - TerminalChannel, } from "./types.ts"; export type { @@ -75,16 +76,18 @@ export type { ProcessRunResponse, ProcessSignalQuery, ProcessState, + ProcessTerminalClientFrame, + ProcessTerminalErrorFrame, + ProcessTerminalExitFrame, + ProcessTerminalReadyFrame, + ProcessTerminalResizeRequest, + ProcessTerminalResizeResponse, + ProcessTerminalServerFrame, SessionEvent, SessionPersistDriver, SessionRecord, SkillsConfig, SkillsConfigQuery, - TerminalErrorStatus, - TerminalExitStatus, - TerminalReadyStatus, - TerminalResizePayload, - TerminalStatusMessage, } from "./types.ts"; export type { diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index 3debf7e..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"]; @@ -46,40 +51,43 @@ export type ProcessRunRequest = JsonRequestBody; export type ProcessSignalQuery = QueryParams; export type ProcessState = components["schemas"]["ProcessState"]; +export type ProcessTerminalResizeRequest = JsonRequestBody; +export type ProcessTerminalResizeResponse = JsonResponse; -export const TerminalChannel = { - stdin: 0, - stdout: 1, - stderr: 2, - status: 3, - resize: 4, - close: 255, -} as const; +export type ProcessTerminalClientFrame = + | { + type: "input"; + data: string; + encoding?: string; + } + | { + type: "resize"; + cols: number; + rows: number; + } + | { + type: "close"; + }; -export interface TerminalReadyStatus { +export interface ProcessTerminalReadyFrame { type: "ready"; processId: string; } -export interface TerminalExitStatus { +export interface ProcessTerminalExitFrame { type: "exit"; exitCode?: number | null; } -export interface TerminalErrorStatus { +export interface ProcessTerminalErrorFrame { type: "error"; message: string; } -export type TerminalStatusMessage = - | TerminalReadyStatus - | TerminalExitStatus - | TerminalErrorStatus; - -export interface TerminalResizePayload { - cols: number; - rows: number; -} +export type ProcessTerminalServerFrame = + | ProcessTerminalReadyFrame + | ProcessTerminalExitFrame + | ProcessTerminalErrorFrame; export interface SessionRecord { id: string; @@ -89,6 +97,8 @@ export interface SessionRecord { createdAt: number; destroyedAt?: number; sessionInit?: Omit; + configOptions?: SessionConfigOption[]; + modes?: SessionModeState | null; } export type SessionEventSender = "client" | "agent"; @@ -228,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/helpers/mock-agent.ts b/sdks/typescript/tests/helpers/mock-agent.ts index 3d5677b..4c6f064 100644 --- a/sdks/typescript/tests/helpers/mock-agent.ts +++ b/sdks/typescript/tests/helpers/mock-agent.ts @@ -1,18 +1,29 @@ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -export function prepareMockAgentDataHome(dataHome: string): void { - const installDir = join(dataHome, "sandbox-agent", "bin"); - const processDir = join(installDir, "agent_processes"); - mkdirSync(processDir, { recursive: true }); +function candidateInstallDirs(dataHome: string): string[] { + const dirs = [join(dataHome, "sandbox-agent", "bin")]; + if (process.platform === "darwin") { + dirs.push(join(dataHome, "Library", "Application Support", "sandbox-agent", "bin")); + } else if (process.platform === "win32") { + dirs.push(join(dataHome, "AppData", "Roaming", "sandbox-agent", "bin")); + } + return dirs; +} - const runner = process.platform === "win32" - ? join(processDir, "mock-acp.cmd") - : join(processDir, "mock-acp"); - - const scriptFile = process.platform === "win32" - ? join(processDir, "mock-acp.js") - : runner; +export function prepareMockAgentDataHome(dataHome: string): Record { + const runtimeEnv: Record = {}; + if (process.platform === "darwin") { + runtimeEnv.HOME = dataHome; + runtimeEnv.XDG_DATA_HOME = join(dataHome, ".local", "share"); + } else if (process.platform === "win32") { + runtimeEnv.USERPROFILE = dataHome; + runtimeEnv.APPDATA = join(dataHome, "AppData", "Roaming"); + runtimeEnv.LOCALAPPDATA = join(dataHome, "AppData", "Local"); + } else { + runtimeEnv.HOME = dataHome; + runtimeEnv.XDG_DATA_HOME = dataHome; + } const nodeScript = String.raw`#!/usr/bin/env node const { createInterface } = require("node:readline"); @@ -127,14 +138,29 @@ rl.on("line", (line) => { }); `; - writeFileSync(scriptFile, nodeScript); + for (const installDir of candidateInstallDirs(dataHome)) { + const processDir = join(installDir, "agent_processes"); + mkdirSync(processDir, { recursive: true }); - if (process.platform === "win32") { - writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`); + const runner = process.platform === "win32" + ? join(processDir, "mock-acp.cmd") + : join(processDir, "mock-acp"); + + const scriptFile = process.platform === "win32" + ? join(processDir, "mock-acp.js") + : runner; + + writeFileSync(scriptFile, nodeScript); + + if (process.platform === "win32") { + writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`); + } + + chmodSync(scriptFile, 0o755); + if (process.platform === "win32") { + chmodSync(runner, 0o755); + } } - chmodSync(scriptFile, 0o755); - if (process.platform === "win32") { - chmodSync(runner, 0o755); - } + return runtimeEnv; } diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 2223b8f..7243aa8 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -136,6 +136,22 @@ function writeTarChecksum(buffer: Buffer, checksum: number): void { buffer[155] = 0x20; } +function decodeSocketPayload(data: unknown): string { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); + } + if (typeof Blob !== "undefined" && data instanceof Blob) { + throw new Error("Blob socket payloads are not supported in this test"); + } + throw new Error(`Unsupported socket payload type: ${typeof data}`); +} + function decodeProcessLogData(data: string, encoding: string): string { if (encoding === "base64") { return Buffer.from(data, "base64").toString("utf8"); @@ -158,15 +174,13 @@ describe("Integration: TypeScript SDK flat session API", () => { beforeAll(async () => { dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-")); - prepareMockAgentDataHome(dataHome); + const agentEnv = prepareMockAgentDataHome(dataHome); handle = await spawnSandboxAgent({ enabled: true, log: "silent", timeoutMs: 30000, - env: { - XDG_DATA_HOME: dataHome, - }, + env: agentEnv, }); baseUrl = handle.baseUrl; token = handle.token; @@ -323,6 +337,111 @@ describe("Integration: TypeScript SDK flat session API", () => { ); }); + it("waits for health before non-ACP HTTP helpers", async () => { + const defaultFetch = globalThis.fetch; + if (!defaultFetch) { + throw new Error("Global fetch is not available in this runtime."); + } + + let healthAttempts = 0; + const seenPaths: string[] = []; + const customFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + const parsed = new URL(outgoing.url); + seenPaths.push(parsed.pathname); + + if (parsed.pathname === "/v1/health") { + healthAttempts += 1; + if (healthAttempts < 3) { + return new Response("warming up", { status: 503 }); + } + } + + const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl); + const forwarded = new Request(forwardedUrl.toString(), outgoing); + return defaultFetch(forwarded); + }; + + const sdk = await SandboxAgent.connect({ + token, + fetch: customFetch, + }); + + const agents = await sdk.listAgents(); + expect(Array.isArray(agents.agents)).toBe(true); + expect(healthAttempts).toBe(3); + + const firstAgentsRequest = seenPaths.indexOf("/v1/agents"); + expect(firstAgentsRequest).toBeGreaterThanOrEqual(0); + expect(seenPaths.slice(0, firstAgentsRequest)).toEqual([ + "/v1/health", + "/v1/health", + "/v1/health", + ]); + + await sdk.dispose(); + }); + + it("surfaces health timeout when a request awaits readiness", async () => { + const customFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + const parsed = new URL(outgoing.url); + + if (parsed.pathname === "/v1/health") { + return new Response("warming up", { status: 503 }); + } + + throw new Error(`Unexpected request path during timeout test: ${parsed.pathname}`); + }; + + const sdk = await SandboxAgent.connect({ + token, + fetch: customFetch, + waitForHealth: { timeoutMs: 100 }, + }); + + await expect(sdk.listAgents()).rejects.toThrow("Timed out waiting for sandbox-agent health"); + await sdk.dispose(); + }); + + it("aborts the shared health wait when connect signal is aborted", async () => { + const controller = new AbortController(); + const customFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + const parsed = new URL(outgoing.url); + + if (parsed.pathname !== "/v1/health") { + throw new Error(`Unexpected request path during abort test: ${parsed.pathname}`); + } + + return new Promise((_resolve, reject) => { + const onAbort = () => { + outgoing.signal.removeEventListener("abort", onAbort); + reject(outgoing.signal.reason ?? new DOMException("Connect aborted", "AbortError")); + }; + + if (outgoing.signal.aborted) { + onAbort(); + return; + } + + outgoing.signal.addEventListener("abort", onAbort, { once: true }); + }); + }; + + const sdk = await SandboxAgent.connect({ + token, + fetch: customFetch, + signal: controller.signal, + }); + + const pending = sdk.listAgents(); + controller.abort(new DOMException("Connect aborted", "AbortError")); + + await expect(pending).rejects.toThrow("Connect aborted"); + await sdk.dispose(); + }); + it("restores a session on stale connection by recreating and replaying history on first prompt", async () => { const persist = new InMemorySessionPersistDriver({ maxEventsPerSession: 200, @@ -401,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, @@ -566,53 +806,47 @@ describe("Integration: TypeScript SDK flat session API", () => { }); ttyProcessId = ttyProcess.id; - const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id); - expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true); - - const session = sdk.connectProcessTerminal(ttyProcess.id, { - WebSocket: WebSocket as unknown as typeof globalThis.WebSocket, - }); - const readyFrames: string[] = []; - const ttyOutput: string[] = []; - const exitFrames: Array = []; - const terminalErrors: string[] = []; - let closeCount = 0; - - session.onReady((status) => { - readyFrames.push(status.processId); - }); - session.onData((bytes) => { - ttyOutput.push(Buffer.from(bytes).toString("utf8")); - }); - session.onExit((status) => { - exitFrames.push(status.exitCode); - }); - session.onError((error) => { - terminalErrors.push(error instanceof Error ? error.message : error.message); - }); - session.onClose(() => { - closeCount += 1; - }); - - await waitFor(() => readyFrames[0]); - - session.resize({ + const resized = await sdk.resizeProcessTerminal(ttyProcess.id, { cols: 120, rows: 40, }); - session.sendInput("hello tty\n"); + expect(resized.cols).toBe(120); + expect(resized.rows).toBe(40); + + const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id); + expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true); + + const ws = sdk.connectProcessTerminalWebSocket(ttyProcess.id, { + WebSocket: WebSocket as unknown as typeof globalThis.WebSocket, + }); + ws.binaryType = "arraybuffer"; + + const socketTextFrames: string[] = []; + const socketBinaryFrames: string[] = []; + ws.addEventListener("message", (event) => { + if (typeof event.data === "string") { + socketTextFrames.push(event.data); + return; + } + socketBinaryFrames.push(decodeSocketPayload(event.data)); + }); await waitFor(() => { - const joined = ttyOutput.join(""); + const ready = socketTextFrames.find((frame) => frame.includes('"type":"ready"')); + return ready; + }); + + ws.send(JSON.stringify({ + type: "input", + data: "hello tty\n", + })); + + await waitFor(() => { + const joined = socketBinaryFrames.join(""); return joined.includes("hello tty") ? joined : undefined; }); - session.close(); - await session.closed; - expect(closeCount).toBeGreaterThan(0); - expect(exitFrames).toHaveLength(0); - expect(terminalErrors).toEqual([]); - + ws.close(); await waitForAsync(async () => { const processInfo = await sdk.getProcess(ttyProcess.id); return processInfo.status === "running" ? processInfo : undefined; diff --git a/server/packages/acp-http-adapter/src/app.rs b/server/packages/acp-http-adapter/src/app.rs index 556892f..cd2e074 100644 --- a/server/packages/acp-http-adapter/src/app.rs +++ b/server/packages/acp-http-adapter/src/app.rs @@ -93,6 +93,20 @@ fn map_error(err: AdapterError) -> Response { "timeout", "timed out waiting for agent response", ), + AdapterError::Exited { exit_code, stderr } => { + let detail = if let Some(stderr) = stderr { + format!( + "agent process exited before responding (exit_code: {:?}, stderr: {})", + exit_code, stderr + ) + } else { + format!( + "agent process exited before responding (exit_code: {:?})", + exit_code + ) + }; + problem(StatusCode::BAD_GATEWAY, "agent_exited", &detail) + } AdapterError::Write(write) => problem( StatusCode::BAD_GATEWAY, "write_failed", diff --git a/server/packages/acp-http-adapter/src/main.rs b/server/packages/acp-http-adapter/src/main.rs index 16b1c0f..7ca7819 100644 --- a/server/packages/acp-http-adapter/src/main.rs +++ b/server/packages/acp-http-adapter/src/main.rs @@ -32,6 +32,7 @@ async fn main() { } async fn run() -> Result<(), Box> { + let started = std::time::Instant::now(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -41,6 +42,12 @@ async fn run() -> Result<(), Box> { .init(); let cli = Cli::parse(); + tracing::info!( + host = %cli.host, + port = cli.port, + startup_ms = started.elapsed().as_millis() as u64, + "acp-http-adapter.run: starting server" + ); run_server(ServerConfig { host: cli.host, port: cli.port, diff --git a/server/packages/acp-http-adapter/src/process.rs b/server/packages/acp-http-adapter/src/process.rs index bfaac2c..74101ed 100644 --- a/server/packages/acp-http-adapter/src/process.rs +++ b/server/packages/acp-http-adapter/src/process.rs @@ -16,6 +16,7 @@ use tokio_stream::wrappers::BroadcastStream; use crate::registry::LaunchSpec; const RING_BUFFER_SIZE: usize = 1024; +const STDERR_TAIL_SIZE: usize = 16; #[derive(Debug, Error)] pub enum AdapterError { @@ -33,6 +34,11 @@ pub enum AdapterError { Serialize(serde_json::Error), #[error("failed to write subprocess stdin: {0}")] Write(std::io::Error), + #[error("agent process exited before responding")] + Exited { + exit_code: Option, + stderr: Option, + }, #[error("timeout waiting for response")] Timeout, } @@ -61,6 +67,7 @@ pub struct AdapterRuntime { shutting_down: AtomicBool, spawned_at: Instant, first_stdout: Arc, + stderr_tail: Arc>>, } impl AdapterRuntime { @@ -120,6 +127,7 @@ impl AdapterRuntime { shutting_down: AtomicBool::new(false), spawned_at: spawn_start, first_stdout: Arc::new(AtomicBool::new(false)), + stderr_tail: Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_SIZE))), }; runtime.spawn_stdout_loop(stdout); @@ -198,6 +206,16 @@ impl AdapterRuntime { "post: response channel dropped (agent process may have exited)" ); self.pending.lock().await.remove(&key); + if let Some((exit_code, stderr)) = self.try_process_exit_info().await { + tracing::error!( + method = %method, + id = %key, + exit_code = ?exit_code, + stderr = ?stderr, + "post: agent process exited before response channel completed" + ); + return Err(AdapterError::Exited { exit_code, stderr }); + } Err(AdapterError::Timeout) } Err(_) => { @@ -213,6 +231,16 @@ impl AdapterRuntime { "post: TIMEOUT waiting for agent response" ); self.pending.lock().await.remove(&key); + if let Some((exit_code, stderr)) = self.try_process_exit_info().await { + tracing::error!( + method = %method, + id = %key, + exit_code = ?exit_code, + stderr = ?stderr, + "post: agent process exited before timeout completed" + ); + return Err(AdapterError::Exited { exit_code, stderr }); + } Err(AdapterError::Timeout) } } @@ -445,6 +473,7 @@ impl AdapterRuntime { fn spawn_stderr_loop(&self, stderr: tokio::process::ChildStderr) { let spawned_at = self.spawned_at; + let stderr_tail = self.stderr_tail.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); @@ -452,6 +481,13 @@ impl AdapterRuntime { while let Ok(Some(line)) = lines.next_line().await { line_count += 1; + { + let mut tail = stderr_tail.lock().await; + tail.push_back(line.clone()); + while tail.len() > STDERR_TAIL_SIZE { + tail.pop_front(); + } + } tracing::info!( line_number = line_count, age_ms = spawned_at.elapsed().as_millis() as u64, @@ -560,6 +596,28 @@ impl AdapterRuntime { tracing::debug!(method = method, id = %id, "stdin: write+flush complete"); Ok(()) } + + async fn try_process_exit_info(&self) -> Option<(Option, Option)> { + let mut child = self.child.lock().await; + match child.try_wait() { + Ok(Some(status)) => { + let exit_code = status.code(); + drop(child); + let stderr = self.stderr_tail_summary().await; + Some((exit_code, stderr)) + } + Ok(None) => None, + Err(_) => None, + } + } + + async fn stderr_tail_summary(&self) -> Option { + let tail = self.stderr_tail.lock().await; + if tail.is_empty() { + return None; + } + Some(tail.iter().cloned().collect::>().join("\n")) + } } fn id_key(value: &Value) -> String { diff --git a/server/packages/agent-management/Cargo.toml b/server/packages/agent-management/Cargo.toml index 799229e..f9ad9cb 100644 --- a/server/packages/agent-management/Cargo.toml +++ b/server/packages/agent-management/Cargo.toml @@ -20,3 +20,4 @@ url.workspace = true dirs.workspace = true tempfile.workspace = true time.workspace = true +tracing.workspace = true diff --git a/server/packages/agent-management/src/agents.rs b/server/packages/agent-management/src/agents.rs index c36d6f8..0796926 100644 --- a/server/packages/agent-management/src/agents.rs +++ b/server/packages/agent-management/src/agents.rs @@ -4,6 +4,7 @@ use std::fs; use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::Instant; use flate2::read::GzDecoder; use reqwest::blocking::Client; @@ -78,7 +79,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 +91,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"), @@ -321,6 +322,14 @@ impl AgentManager { agent: AgentId, options: InstallOptions, ) -> Result { + let install_started = Instant::now(); + tracing::info!( + agent = agent.as_str(), + reinstall = options.reinstall, + native_version = ?options.version, + agent_process_version = ?options.agent_process_version, + "agent_manager.install: starting" + ); fs::create_dir_all(&self.install_dir)?; fs::create_dir_all(self.install_dir.join("agent_processes"))?; @@ -345,10 +354,20 @@ impl AgentManager { artifacts.push(artifact); } - Ok(InstallResult { + let result = InstallResult { artifacts, already_installed, - }) + }; + + tracing::info!( + agent = agent.as_str(), + already_installed = result.already_installed, + artifact_count = result.artifacts.len(), + total_ms = elapsed_ms(install_started), + "agent_manager.install: completed" + ); + + Ok(result) } pub fn is_installed(&self, agent: AgentId) -> bool { @@ -392,25 +411,41 @@ impl AgentManager { &self, agent: AgentId, ) -> Result { + let started = Instant::now(); if agent == AgentId::Mock { - return Ok(AgentProcessLaunchSpec { + let spec = AgentProcessLaunchSpec { program: self.agent_process_path(agent), args: Vec::new(), env: HashMap::new(), source: InstallSource::Builtin, version: Some("builtin".to_string()), - }); + }; + tracing::info!( + agent = agent.as_str(), + source = ?spec.source, + total_ms = elapsed_ms(started), + "agent_manager.resolve_agent_process: resolved builtin" + ); + return Ok(spec); } let launcher = self.agent_process_path(agent); if launcher.exists() { - return Ok(AgentProcessLaunchSpec { + let spec = AgentProcessLaunchSpec { program: launcher, args: Vec::new(), env: HashMap::new(), source: InstallSource::LocalPath, version: None, - }); + }; + tracing::info!( + agent = agent.as_str(), + source = ?spec.source, + program = %spec.program.display(), + total_ms = elapsed_ms(started), + "agent_manager.resolve_agent_process: resolved local launcher" + ); + return Ok(spec); } if let Some(bin) = agent.agent_process_binary_hint().and_then(find_in_path) { @@ -419,29 +454,47 @@ impl AgentManager { } else { Vec::new() }; - return Ok(AgentProcessLaunchSpec { + let spec = AgentProcessLaunchSpec { program: bin, args, env: HashMap::new(), source: InstallSource::LocalPath, version: None, - }); + }; + tracing::info!( + agent = agent.as_str(), + source = ?spec.source, + program = %spec.program.display(), + args = ?spec.args, + total_ms = elapsed_ms(started), + "agent_manager.resolve_agent_process: resolved PATH binary hint" + ); + return Ok(spec); } if agent == AgentId::Opencode { let native = self.resolve_binary(agent)?; - return Ok(AgentProcessLaunchSpec { + let spec = AgentProcessLaunchSpec { program: native, args: vec!["acp".to_string()], env: HashMap::new(), source: InstallSource::LocalPath, version: None, - }); + }; + tracing::info!( + agent = agent.as_str(), + source = ?spec.source, + program = %spec.program.display(), + args = ?spec.args, + total_ms = elapsed_ms(started), + "agent_manager.resolve_agent_process: resolved opencode native" + ); + return Ok(spec); } Err(AgentError::AgentProcessNotFound { agent, - hint: Some("run install to provision ACP agent process".to_string()), + hint: Some(format!("run step 3: `sandbox-agent install-agent {agent}`")), }) } @@ -454,11 +507,23 @@ impl AgentManager { agent: AgentId, options: &InstallOptions, ) -> Result, AgentError> { + let started = Instant::now(); if !options.reinstall && self.native_installed(agent) { + tracing::info!( + agent = agent.as_str(), + total_ms = elapsed_ms(started), + "agent_manager.install_native: already installed" + ); return Ok(None); } let path = self.binary_path(agent); + tracing::info!( + agent = agent.as_str(), + path = %path.display(), + version_override = ?options.version, + "agent_manager.install_native: installing" + ); match agent { AgentId::Claude => install_claude(&path, self.platform, options.version.as_deref())?, AgentId::Codex => install_codex(&path, self.platform, options.version.as_deref())?, @@ -474,12 +539,22 @@ impl AgentManager { } } - Ok(Some(InstalledArtifact { + let artifact = InstalledArtifact { kind: InstalledArtifactKind::NativeAgent, path, version: self.version(agent).ok().flatten(), source: InstallSource::Fallback, - })) + }; + + tracing::info!( + agent = agent.as_str(), + source = ?artifact.source, + version = ?artifact.version, + total_ms = elapsed_ms(started), + "agent_manager.install_native: completed" + ); + + Ok(Some(artifact)) } fn install_agent_process( @@ -487,8 +562,14 @@ impl AgentManager { agent: AgentId, options: &InstallOptions, ) -> Result, AgentError> { + let started = Instant::now(); if !options.reinstall { if self.agent_process_status(agent).is_some() { + tracing::info!( + agent = agent.as_str(), + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process: already installed" + ); return Ok(None); } } @@ -496,22 +577,104 @@ impl AgentManager { if agent == AgentId::Mock { let path = self.agent_process_path(agent); write_mock_agent_process_launcher(&path)?; - return Ok(Some(InstalledArtifact { + let artifact = InstalledArtifact { kind: InstalledArtifactKind::AgentProcess, path, version: Some("builtin".to_string()), source: InstallSource::Builtin, - })); + }; + tracing::info!( + agent = agent.as_str(), + source = ?artifact.source, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process: installed builtin launcher" + ); + return Ok(Some(artifact)); } if let Some(artifact) = self.install_agent_process_from_registry(agent, options)? { + tracing::info!( + agent = agent.as_str(), + source = ?artifact.source, + version = ?artifact.version, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process: installed from registry" + ); return Ok(Some(artifact)); } let artifact = self.install_agent_process_fallback(agent, options)?; + tracing::info!( + agent = agent.as_str(), + source = ?artifact.source, + version = ?artifact.version, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process: installed from fallback" + ); Ok(Some(artifact)) } + fn install_npm_agent_process_package( + &self, + agent: AgentId, + package: &str, + args: &[String], + env: &HashMap, + source: InstallSource, + version: Option, + ) -> Result { + let started = Instant::now(); + let root = self.agent_process_storage_dir(agent); + if root.exists() { + fs::remove_dir_all(&root)?; + } + fs::create_dir_all(&root)?; + + let npm_install_started = Instant::now(); + install_npm_package(&root, package, agent)?; + let npm_install_ms = elapsed_ms(npm_install_started); + + let bin_name = agent.agent_process_binary_hint().ok_or_else(|| { + AgentError::ExtractFailed(format!( + "missing executable hint for agent process package: {agent}" + )) + })?; + + let cmd_path = npm_bin_path(&root, bin_name); + if !cmd_path.exists() { + return Err(AgentError::ExtractFailed(format!( + "installed package missing executable: {}", + cmd_path.display() + ))); + } + + let launcher = self.agent_process_path(agent); + let write_started = Instant::now(); + write_exec_agent_process_launcher(&launcher, &cmd_path, args, env)?; + let write_ms = elapsed_ms(write_started); + let verify_started = Instant::now(); + verify_command(&launcher, &[])?; + let verify_ms = elapsed_ms(verify_started); + + tracing::info!( + agent = agent.as_str(), + package = %package, + cmd = %cmd_path.display(), + npm_install_ms = npm_install_ms, + write_ms = write_ms, + verify_ms = verify_ms, + total_ms = elapsed_ms(started), + "agent_manager.install_npm_agent_process_package: completed" + ); + + Ok(InstalledArtifact { + kind: InstalledArtifactKind::AgentProcess, + path: launcher, + version, + source, + }) + } + fn agent_process_status(&self, agent: AgentId) -> Option { if agent == AgentId::Mock { return Some(AgentProcessStatus { @@ -540,59 +703,111 @@ impl AgentManager { agent: AgentId, options: &InstallOptions, ) -> Result, AgentError> { + let started = Instant::now(); let Some(registry_id) = agent.agent_process_registry_id() else { return Ok(None); }; + tracing::info!( + agent = agent.as_str(), + registry_id = registry_id, + url = %self.registry_url, + "agent_manager.install_agent_process_from_registry: fetching registry" + ); + let fetch_started = Instant::now(); let registry = fetch_registry(&self.registry_url)?; + tracing::info!( + agent = agent.as_str(), + registry_id = registry_id, + fetch_ms = elapsed_ms(fetch_started), + "agent_manager.install_agent_process_from_registry: registry fetched" + ); let Some(entry) = registry.agents.into_iter().find(|a| a.id == registry_id) else { + tracing::info!( + agent = agent.as_str(), + registry_id = registry_id, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process_from_registry: missing entry" + ); return Ok(None); }; if let Some(npx) = entry.distribution.npx { let package = apply_npx_version_override(&npx.package, options.agent_process_version.as_deref()); - let launcher = self.agent_process_path(agent); - write_npx_agent_process_launcher(&launcher, &package, &npx.args, &npx.env)?; - verify_command(&launcher, &[])?; - return Ok(Some(InstalledArtifact { - kind: InstalledArtifactKind::AgentProcess, - path: launcher, - version: options - .agent_process_version - .clone() - .or(entry.version) - .or(extract_npx_version(&package)), - source: InstallSource::Registry, - })); + let version = options + .agent_process_version + .clone() + .or(entry.version) + .or(extract_npx_version(&package)); + let artifact = self.install_npm_agent_process_package( + agent, + &package, + &npx.args, + &npx.env, + InstallSource::Registry, + version, + )?; + tracing::info!( + agent = agent.as_str(), + package = %package, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process_from_registry: npm package installed" + ); + return Ok(Some(artifact)); } if let Some(binary) = entry.distribution.binary { let key = self.platform.registry_key(); if let Some(target) = binary.get(key) { let archive_url = Url::parse(&target.archive)?; + let download_started = Instant::now(); let payload = download_bytes(&archive_url)?; + let download_ms = elapsed_ms(download_started); let root = self.agent_process_storage_dir(agent); if root.exists() { fs::remove_dir_all(&root)?; } fs::create_dir_all(&root)?; + let unpack_started = Instant::now(); unpack_archive(&payload, &archive_url, &root)?; + let unpack_ms = elapsed_ms(unpack_started); let cmd_path = resolve_extracted_command(&root, &target.cmd)?; let launcher = self.agent_process_path(agent); + let write_started = Instant::now(); write_exec_agent_process_launcher(&launcher, &cmd_path, &target.args, &target.env)?; + let write_ms = elapsed_ms(write_started); + let verify_started = Instant::now(); verify_command(&launcher, &[])?; + let verify_ms = elapsed_ms(verify_started); - return Ok(Some(InstalledArtifact { + let artifact = InstalledArtifact { kind: InstalledArtifactKind::AgentProcess, path: launcher, version: options.agent_process_version.clone().or(entry.version), source: InstallSource::Registry, - })); + }; + tracing::info!( + agent = agent.as_str(), + archive_url = %archive_url, + download_ms = download_ms, + unpack_ms = unpack_ms, + write_ms = write_ms, + verify_ms = verify_ms, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process_from_registry: binary launcher installed" + ); + return Ok(Some(artifact)); } } + tracing::info!( + agent = agent.as_str(), + registry_id = registry_id, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process_from_registry: no compatible distribution" + ); Ok(None) } @@ -601,24 +816,44 @@ impl AgentManager { agent: AgentId, options: &InstallOptions, ) -> Result { - let launcher = self.agent_process_path(agent); - - match agent { + let started = Instant::now(); + let artifact = 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())?; + self.install_npm_agent_process_package( + agent, + &package, + &[], + &HashMap::new(), + InstallSource::Fallback, + options + .agent_process_version + .clone() + .or(extract_npx_version(&package)), + )? } AgentId::Codex => { let package = fallback_npx_package( "@zed-industries/codex-acp", options.agent_process_version.as_deref(), ); - write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + self.install_npm_agent_process_package( + agent, + &package, + &[], + &HashMap::new(), + InstallSource::Fallback, + options + .agent_process_version + .clone() + .or(extract_npx_version(&package)), + )? } AgentId::Opencode => { + let launcher = self.agent_process_path(agent); let native = self.resolve_binary(agent)?; write_exec_agent_process_launcher( &launcher, @@ -626,37 +861,82 @@ impl AgentManager { &["acp".to_string()], &HashMap::new(), )?; + verify_command(&launcher, &[])?; + InstalledArtifact { + kind: InstalledArtifactKind::AgentProcess, + path: launcher, + version: options.agent_process_version.clone(), + source: InstallSource::Fallback, + } } AgentId::Amp => { let package = fallback_npx_package("amp-acp", options.agent_process_version.as_deref()); - write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + self.install_npm_agent_process_package( + agent, + &package, + &[], + &HashMap::new(), + InstallSource::Fallback, + options + .agent_process_version + .clone() + .or(extract_npx_version(&package)), + )? } AgentId::Pi => { let package = fallback_npx_package("pi-acp", options.agent_process_version.as_deref()); - write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + self.install_npm_agent_process_package( + agent, + &package, + &[], + &HashMap::new(), + InstallSource::Fallback, + options + .agent_process_version + .clone() + .or(extract_npx_version(&package)), + )? } AgentId::Cursor => { let package = fallback_npx_package( "@blowmage/cursor-agent-acp", options.agent_process_version.as_deref(), ); - write_npx_agent_process_launcher(&launcher, &package, &[], &HashMap::new())?; + self.install_npm_agent_process_package( + agent, + &package, + &[], + &HashMap::new(), + InstallSource::Fallback, + options + .agent_process_version + .clone() + .or(extract_npx_version(&package)), + )? } AgentId::Mock => { + let launcher = self.agent_process_path(agent); write_mock_agent_process_launcher(&launcher)?; + InstalledArtifact { + kind: InstalledArtifactKind::AgentProcess, + path: launcher, + version: options.agent_process_version.clone(), + source: InstallSource::Fallback, + } } - } + }; - verify_command(&launcher, &[])?; + tracing::info!( + agent = agent.as_str(), + source = ?artifact.source, + version = ?artifact.version, + total_ms = elapsed_ms(started), + "agent_manager.install_agent_process_fallback: launcher installed" + ); - Ok(InstalledArtifact { - kind: InstalledArtifactKind::AgentProcess, - path: launcher, - version: options.agent_process_version.clone(), - source: InstallSource::Fallback, - }) + Ok(artifact) } } @@ -732,6 +1012,10 @@ pub enum AgentError { RegistryParse(String), #[error("command verification failed: {0}")] VerifyFailed(String), + #[error( + "npm is required to install {agent}. install npm, then run step 3: `sandbox-agent install-agent {agent}`" + )] + MissingNpm { agent: AgentId }, } fn fallback_npx_package(base: &str, version: Option<&str>) -> String { @@ -779,15 +1063,36 @@ fn split_package_version(package: &str) -> Option<(&str, &str)> { } } -fn write_npx_agent_process_launcher( - path: &Path, - package: &str, - args: &[String], - env: &HashMap, -) -> Result<(), AgentError> { - let mut command = vec!["npx".to_string(), "-y".to_string(), package.to_string()]; - command.extend(args.iter().cloned()); - write_launcher(path, &command, env) +fn install_npm_package(root: &Path, package: &str, agent: AgentId) -> Result<(), AgentError> { + let mut command = Command::new("npm"); + command + .arg("install") + .arg("--no-audit") + .arg("--no-fund") + .arg("--prefix") + .arg(root) + .arg(package) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + match command.status() { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(AgentError::VerifyFailed(format!( + "npm install failed for {agent} with status {status}. run step 3: `sandbox-agent install-agent {agent}`" + ))), + Err(err) if err.kind() == io::ErrorKind::NotFound => Err(AgentError::MissingNpm { agent }), + Err(err) => Err(AgentError::VerifyFailed(format!( + "failed to execute npm for {agent}: {err}" + ))), + } +} + +fn npm_bin_path(root: &Path, bin_name: &str) -> PathBuf { + let mut path = root.join("node_modules").join(".bin").join(bin_name); + if cfg!(windows) { + path.set_extension("cmd"); + } + path } fn write_exec_agent_process_launcher( @@ -998,6 +1303,15 @@ fn install_claude( platform: Platform, version: Option<&str>, ) -> Result<(), AgentError> { + let started = Instant::now(); + tracing::info!( + path = %path.display(), + platform = ?platform, + version_override = ?version, + "agent_manager.install_claude: starting" + ); + + let version_started = Instant::now(); let version = match version { Some(version) => version.to_string(), None => { @@ -1009,6 +1323,7 @@ fn install_claude( text.trim().to_string() } }; + let version_ms = elapsed_ms(version_started); let platform_segment = match platform { Platform::LinuxX64 => "linux-x64", @@ -1023,12 +1338,26 @@ fn install_claude( let url = Url::parse(&format!( "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/{platform_segment}/claude" ))?; + let download_started = Instant::now(); let bytes = download_bytes(&url)?; + let download_ms = elapsed_ms(download_started); + let write_started = Instant::now(); write_executable(path, &bytes)?; + tracing::info!( + version = %version, + url = %url, + bytes = bytes.len(), + version_ms = version_ms, + download_ms = download_ms, + write_ms = elapsed_ms(write_started), + total_ms = elapsed_ms(started), + "agent_manager.install_claude: completed" + ); Ok(()) } fn install_amp(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> { + let started = Instant::now(); let version = match version { Some(version) => version.to_string(), None => { @@ -1053,12 +1382,25 @@ fn install_amp(path: &Path, platform: Platform, version: Option<&str>) -> Result let url = Url::parse(&format!( "https://storage.googleapis.com/amp-public-assets-prod-0/cli/{version}/amp-{platform_segment}" ))?; + let download_started = Instant::now(); let bytes = download_bytes(&url)?; + let download_ms = elapsed_ms(download_started); + let write_started = Instant::now(); write_executable(path, &bytes)?; + tracing::info!( + version = %version, + url = %url, + bytes = bytes.len(), + download_ms = download_ms, + write_ms = elapsed_ms(write_started), + total_ms = elapsed_ms(started), + "agent_manager.install_amp: completed" + ); Ok(()) } fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> { + let started = Instant::now(); let target = match platform { Platform::LinuxX64 | Platform::LinuxX64Musl => "x86_64-unknown-linux-musl", Platform::LinuxArm64 => "aarch64-unknown-linux-musl", @@ -1077,11 +1419,15 @@ fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Resu ))?, }; + let download_started = Instant::now(); let bytes = download_bytes(&url)?; + let download_ms = elapsed_ms(download_started); let temp_dir = tempfile::tempdir()?; + let unpack_started = Instant::now(); let cursor = io::Cursor::new(bytes); let mut archive = tar::Archive::new(GzDecoder::new(cursor)); archive.unpack(temp_dir.path())?; + let unpack_ms = elapsed_ms(unpack_started); let expected = if cfg!(windows) { format!("codex-{target}.exe") @@ -1091,7 +1437,17 @@ fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Resu let binary = find_file_recursive(temp_dir.path(), &expected)? .ok_or_else(|| AgentError::ExtractFailed(format!("missing {expected}")))?; + let move_started = Instant::now(); move_executable(&binary, path)?; + tracing::info!( + url = %url, + target = target, + download_ms = download_ms, + unpack_ms = unpack_ms, + move_ms = elapsed_ms(move_started), + total_ms = elapsed_ms(started), + "agent_manager.install_codex: completed" + ); Ok(()) } @@ -1100,7 +1456,15 @@ fn install_opencode( platform: Platform, version: Option<&str>, ) -> Result<(), AgentError> { - match platform { + let started = Instant::now(); + tracing::info!( + path = %path.display(), + platform = ?platform, + version_override = ?version, + "agent_manager.install_opencode: starting" + ); + + let result = match platform { Platform::MacosArm64 => { let url = match version { Some(version) => Url::parse(&format!( @@ -1141,22 +1505,46 @@ fn install_opencode( ))?, }; + let download_started = Instant::now(); let bytes = download_bytes(&url)?; + let download_ms = elapsed_ms(download_started); let temp_dir = tempfile::tempdir()?; + let unpack_started = Instant::now(); let cursor = io::Cursor::new(bytes); let mut archive = tar::Archive::new(GzDecoder::new(cursor)); archive.unpack(temp_dir.path())?; + let unpack_ms = elapsed_ms(unpack_started); let binary = find_file_recursive(temp_dir.path(), "opencode") .or_else(|_| find_file_recursive(temp_dir.path(), "opencode.exe"))? .ok_or_else(|| AgentError::ExtractFailed("missing opencode".to_string()))?; + let move_started = Instant::now(); move_executable(&binary, path)?; + tracing::info!( + url = %url, + download_ms = download_ms, + unpack_ms = unpack_ms, + move_ms = elapsed_ms(move_started), + "agent_manager.install_opencode: tarball extraction complete" + ); Ok(()) } + }; + + if result.is_ok() { + tracing::info!( + total_ms = elapsed_ms(started), + "agent_manager.install_opencode: completed" + ); } + + result } fn install_zip_binary(path: &Path, url: &Url, binary_name: &str) -> Result<(), AgentError> { + let started = Instant::now(); + let download_started = Instant::now(); let bytes = download_bytes(url)?; + let download_ms = elapsed_ms(download_started); let reader = io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(reader).map_err(|err| AgentError::ExtractFailed(err.to_string()))?; @@ -1173,7 +1561,16 @@ fn install_zip_binary(path: &Path, url: &Url, binary_name: &str) -> Result<(), A let out_path = temp_dir.path().join(binary_name); let mut out_file = fs::File::create(&out_path)?; io::copy(&mut file, &mut out_file)?; + let move_started = Instant::now(); move_executable(&out_path, path)?; + tracing::info!( + url = %url, + binary_name = binary_name, + download_ms = download_ms, + move_ms = elapsed_ms(move_started), + total_ms = elapsed_ms(started), + "agent_manager.install_zip_binary: completed" + ); return Ok(()); } Err(AgentError::ExtractFailed(format!("missing {binary_name}"))) @@ -1231,6 +1628,10 @@ fn find_file_recursive(dir: &Path, filename: &str) -> Result, Ag Ok(None) } +fn elapsed_ms(start: Instant) -> u64 { + start.elapsed().as_millis() as u64 +} + fn parse_version_output(output: &std::process::Output) -> Option { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -1265,6 +1666,38 @@ mod tests { } } + fn write_fake_npm(path: &Path) { + write_exec( + path, + r#"#!/usr/bin/env sh +set -e +prefix="" +while [ "$#" -gt 0 ]; do + case "$1" in + install|--no-audit|--no-fund) + shift + ;; + --prefix) + prefix="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +[ -n "$prefix" ] || exit 1 +mkdir -p "$prefix/node_modules/.bin" +for bin in claude-code-acp codex-acp amp-acp pi-acp cursor-agent-acp; do + echo '#!/usr/bin/env sh' > "$prefix/node_modules/.bin/$bin" + echo 'exit 0' >> "$prefix/node_modules/.bin/$bin" + chmod +x "$prefix/node_modules/.bin/$bin" +done +exit 0 +"#, + ); + } + fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -1407,7 +1840,7 @@ mod tests { let bin_dir = temp_dir.path().join("bin"); fs::create_dir_all(&bin_dir).expect("create bin dir"); - write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + write_fake_npm(&bin_dir.join("npm")); let original_path = std::env::var_os("PATH").unwrap_or_default(); let mut paths = vec![bin_dir.clone()]; @@ -1455,8 +1888,8 @@ mod tests { let launcher = fs::read_to_string(manager.agent_process_path(AgentId::Codex)).expect("launcher"); assert!( - launcher.contains("@example/codex-acp@9.9.9"), - "launcher should include overridden package version" + launcher.contains("node_modules/.bin/codex-acp"), + "launcher should invoke installed codex executable" ); } @@ -1474,7 +1907,7 @@ mod tests { let bin_dir = temp_dir.path().join("bin"); fs::create_dir_all(&bin_dir).expect("create bin dir"); - write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + write_fake_npm(&bin_dir.join("npm")); let original_path = std::env::var_os("PATH").unwrap_or_default(); let mut paths = vec![bin_dir.clone()]; @@ -1496,6 +1929,39 @@ mod tests { assert_eq!(agent_process_artifact.source, InstallSource::Fallback); } + #[test] + fn install_returns_missing_npm_error_for_npm_backed_agents() { + let _env_lock = env_lock().lock().expect("env lock"); + + let temp_dir = tempfile::tempdir().expect("create tempdir"); + let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64); + + write_exec( + &manager.binary_path(AgentId::Codex), + "#!/usr/bin/env sh\nexit 0\n", + ); + + let bin_dir = temp_dir.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let combined_path = std::env::join_paths([bin_dir]).expect("join PATH"); + let _path_guard = EnvVarGuard::set("PATH", &combined_path); + + manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] })); + + let error = manager + .install(AgentId::Codex, InstallOptions::default()) + .expect_err("install should fail without npm"); + + match error { + AgentError::MissingNpm { agent } => assert_eq!(agent, AgentId::Codex), + other => panic!("expected MissingNpm, got {other:?}"), + } + + drop(original_path); + } + #[test] fn reinstall_mock_returns_agent_process_artifact() { let temp_dir = tempfile::tempdir().expect("create tempdir"); @@ -1522,7 +1988,7 @@ mod tests { } #[test] - fn install_pi_skips_native_and_writes_fallback_npx_launcher() { + fn install_pi_skips_native_and_installs_fallback_npm_launcher() { let _env_lock = env_lock().lock().expect("env lock"); let temp_dir = tempfile::tempdir().expect("create tempdir"); @@ -1530,7 +1996,7 @@ mod tests { let bin_dir = temp_dir.path().join("bin"); fs::create_dir_all(&bin_dir).expect("create bin dir"); - write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + write_fake_npm(&bin_dir.join("npm")); let original_path = std::env::var_os("PATH").unwrap_or_default(); let mut paths = vec![bin_dir.clone()]; @@ -1564,8 +2030,8 @@ mod tests { let launcher = fs::read_to_string(manager.agent_process_path(AgentId::Pi)).expect("read pi launcher"); assert!( - launcher.contains("pi-acp"), - "pi launcher should reference pi-acp package" + launcher.contains("node_modules/.bin/pi-acp"), + "pi launcher should use installed pi executable" ); // resolve_agent_process should now find it. @@ -1590,7 +2056,7 @@ mod tests { } #[test] - fn install_cursor_skips_native_and_writes_fallback_npx_launcher() { + fn install_cursor_skips_native_and_installs_fallback_npm_launcher() { let _env_lock = env_lock().lock().expect("env lock"); let temp_dir = tempfile::tempdir().expect("create tempdir"); @@ -1598,7 +2064,7 @@ mod tests { let bin_dir = temp_dir.path().join("bin"); fs::create_dir_all(&bin_dir).expect("create bin dir"); - write_exec(&bin_dir.join("npx"), "#!/usr/bin/env sh\nexit 0\n"); + write_fake_npm(&bin_dir.join("npm")); let original_path = std::env::var_os("PATH").unwrap_or_default(); let mut paths = vec![bin_dir.clone()]; @@ -1630,8 +2096,8 @@ mod tests { let launcher = fs::read_to_string(manager.agent_process_path(AgentId::Cursor)) .expect("read cursor launcher"); assert!( - launcher.contains("@blowmage/cursor-agent-acp"), - "cursor launcher should reference @blowmage/cursor-agent-acp package" + launcher.contains("node_modules/.bin/cursor-agent-acp"), + "cursor launcher should use installed cursor executable" ); let spec = manager diff --git a/server/packages/sandbox-agent/src/acp_proxy_runtime.rs b/server/packages/sandbox-agent/src/acp_proxy_runtime.rs index 9e1e13c..47bc2b0 100644 --- a/server/packages/sandbox-agent/src/acp_proxy_runtime.rs +++ b/server/packages/sandbox-agent/src/acp_proxy_runtime.rs @@ -165,7 +165,7 @@ impl AcpProxyRuntime { error = %err, "acp_proxy: POST → error" ); - Err(map_adapter_error(err)) + Err(map_adapter_error(err, Some(instance.agent))) } } } @@ -277,27 +277,28 @@ impl AcpProxyRuntime { server_id: &str, agent: AgentId, ) -> Result, SandboxError> { - let start = std::time::Instant::now(); + let total_started = std::time::Instant::now(); tracing::info!( server_id = server_id, agent = agent.as_str(), "create_instance: starting" ); + let install_started = std::time::Instant::now(); self.ensure_installed(agent).await?; - let install_elapsed = start.elapsed(); tracing::info!( server_id = server_id, agent = agent.as_str(), - install_ms = install_elapsed.as_millis() as u64, + install_ms = install_started.elapsed().as_millis() as u64, "create_instance: agent installed/verified" ); + let resolve_started = std::time::Instant::now(); let manager = self.inner.agent_manager.clone(); let launch = tokio::task::spawn_blocking(move || manager.resolve_agent_process(agent)) .await .map_err(|err| SandboxError::StreamError { - message: format!("failed to resolve ACP agent process launch spec: {err}"), + message: format!("failed to resolve agent process launch spec: {err}"), })? .map_err(|err| SandboxError::StreamError { message: err.to_string(), @@ -308,10 +309,11 @@ impl AcpProxyRuntime { agent = agent.as_str(), program = ?launch.program, args = ?launch.args, - resolve_ms = start.elapsed().as_millis() as u64, + resolve_ms = resolve_started.elapsed().as_millis() as u64, "create_instance: launch spec resolved, spawning" ); + let spawn_started = std::time::Instant::now(); let runtime = AdapterRuntime::start( LaunchSpec { program: launch.program, @@ -321,12 +323,13 @@ impl AcpProxyRuntime { self.inner.request_timeout, ) .await - .map_err(map_adapter_error)?; + .map_err(|err| map_adapter_error(err, Some(agent)))?; - let total_ms = start.elapsed().as_millis() as u64; + let total_ms = total_started.elapsed().as_millis() as u64; tracing::info!( server_id = server_id, agent = agent.as_str(), + spawn_ms = spawn_started.elapsed().as_millis() as u64, total_ms = total_ms, "create_instance: ready" ); @@ -340,16 +343,27 @@ impl AcpProxyRuntime { } async fn ensure_installed(&self, agent: AgentId) -> Result<(), SandboxError> { + let started = std::time::Instant::now(); if self.inner.require_preinstall { if !self.is_ready(agent).await { return Err(SandboxError::AgentNotInstalled { agent: agent.as_str().to_string(), }); } + tracing::info!( + agent = agent.as_str(), + total_ms = started.elapsed().as_millis() as u64, + "ensure_installed: preinstall requirement satisfied" + ); return Ok(()); } if self.is_ready(agent).await { + tracing::info!( + agent = agent.as_str(), + total_ms = started.elapsed().as_millis() as u64, + "ensure_installed: already ready" + ); return Ok(()); } @@ -363,9 +377,19 @@ impl AcpProxyRuntime { let _guard = lock.lock().await; if self.is_ready(agent).await { + tracing::info!( + agent = agent.as_str(), + total_ms = started.elapsed().as_millis() as u64, + "ensure_installed: became ready while waiting for lock" + ); return Ok(()); } + tracing::info!( + agent = agent.as_str(), + "ensure_installed: installing missing artifacts" + ); + let install_started = std::time::Instant::now(); let manager = self.inner.agent_manager.clone(); tokio::task::spawn_blocking(move || manager.install(agent, InstallOptions::default())) .await @@ -378,6 +402,12 @@ impl AcpProxyRuntime { stderr: Some(err.to_string()), })?; + tracing::info!( + agent = agent.as_str(), + install_ms = install_started.elapsed().as_millis() as u64, + total_ms = started.elapsed().as_millis() as u64, + "ensure_installed: install complete" + ); Ok(()) } @@ -432,7 +462,7 @@ impl AcpDispatch for AcpProxyRuntime { } } -fn map_adapter_error(err: AdapterError) -> SandboxError { +fn map_adapter_error(err: AdapterError, agent: Option) -> SandboxError { match err { AdapterError::InvalidEnvelope => SandboxError::InvalidRequest { message: "request body must be a JSON-RPC object".to_string(), @@ -446,6 +476,29 @@ fn map_adapter_error(err: AdapterError) -> SandboxError { AdapterError::Write(error) => SandboxError::StreamError { message: format!("failed writing to agent stdin: {error}"), }, + AdapterError::Exited { exit_code, stderr } => { + if let Some(agent) = agent { + SandboxError::AgentProcessExited { + agent: agent.as_str().to_string(), + exit_code, + stderr, + } + } else { + SandboxError::StreamError { + message: if let Some(stderr) = stderr { + format!( + "agent process exited before responding (exit_code: {:?}, stderr: {})", + exit_code, stderr + ) + } else { + format!( + "agent process exited before responding (exit_code: {:?})", + exit_code + ) + }, + } + } + } AdapterError::Spawn(error) => SandboxError::StreamError { message: format!("failed to start agent process: {error}"), }, 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/process_runtime.rs b/server/packages/sandbox-agent/src/process_runtime.rs index 24dde91..cd1bedd 100644 --- a/server/packages/sandbox-agent/src/process_runtime.rs +++ b/server/packages/sandbox-agent/src/process_runtime.rs @@ -8,7 +8,7 @@ use base64::Engine; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::process::{Child, ChildStdin, Command}; -use tokio::sync::{broadcast, Mutex, RwLock, Semaphore}; +use tokio::sync::{broadcast, Mutex, RwLock}; use sandbox_agent_error::SandboxError; @@ -119,7 +119,6 @@ pub struct ProcessRuntime { struct ProcessRuntimeInner { next_id: AtomicU64, processes: RwLock>>, - run_once_semaphore: Semaphore, } #[derive(Debug)] @@ -183,9 +182,6 @@ impl ProcessRuntime { inner: Arc::new(ProcessRuntimeInner { next_id: AtomicU64::new(1), processes: RwLock::new(HashMap::new()), - run_once_semaphore: Semaphore::new( - ProcessRuntimeConfig::default().max_concurrent_processes, - ), }), } } @@ -328,14 +324,6 @@ impl ProcessRuntime { }); } - let _permit = - self.inner - .run_once_semaphore - .try_acquire() - .map_err(|_| SandboxError::Conflict { - message: "too many concurrent run_once operations".to_string(), - })?; - let config = self.get_config().await; let mut timeout_ms = spec.timeout_ms.unwrap_or(config.default_run_timeout_ms); if timeout_ms == 0 { @@ -358,6 +346,9 @@ impl ProcessRuntime { cmd.current_dir(cwd); } + if !spec.env.contains_key("TERM") { + cmd.env("TERM", "xterm-256color"); + } for (key, value) in &spec.env { cmd.env(key, value); } @@ -622,10 +613,6 @@ impl ProcessRuntime { cmd.current_dir(cwd); } - // Default TERM for TTY processes so tools like tmux, vim, etc. work out of the box. - if !spec.env.contains_key("TERM") { - cmd.env("TERM", "xterm-256color"); - } for (key, value) in &spec.env { cmd.env(key, value); } diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 11211ff..110c325 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -50,12 +50,6 @@ pub use self::types::*; const APPLICATION_JSON: &str = "application/json"; const TEXT_EVENT_STREAM: &str = "text/event-stream"; -const CHANNEL_K8S_IO_PROTOCOL: &str = "channel.k8s.io"; -const CH_STDIN: u8 = 0; -const CH_STDOUT: u8 = 1; -const CH_STATUS: u8 = 3; -const CH_RESIZE: u8 = 4; -const CH_CLOSE: u8 = 255; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BrandingMode { @@ -202,6 +196,10 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) .route("/processes/:id/kill", post(post_v1_process_kill)) .route("/processes/:id/logs", get(get_v1_process_logs)) .route("/processes/:id/input", post(post_v1_process_input)) + .route( + "/processes/:id/terminal/resize", + post(post_v1_process_terminal_resize), + ) .route( "/processes/:id/terminal/ws", get(get_v1_process_terminal_ws), @@ -346,6 +344,7 @@ pub async fn shutdown_servers(state: &Arc) { delete_v1_process, get_v1_process_logs, post_v1_process_input, + post_v1_process_terminal_resize, get_v1_process_terminal_ws, get_v1_config_mcp, put_v1_config_mcp, @@ -395,6 +394,8 @@ pub async fn shutdown_servers(state: &Arc) { ProcessInputRequest, ProcessInputResponse, ProcessSignalQuery, + ProcessTerminalResizeRequest, + ProcessTerminalResizeResponse, AcpPostQuery, AcpServerInfo, AcpServerListResponse, @@ -1493,15 +1494,12 @@ async fn get_v1_process_logs( since, }; - if query.follow.unwrap_or(false) { - // Subscribe before reading history to avoid losing entries between the - // two operations. Entries are deduplicated by sequence number below. - let rx = runtime.subscribe_logs(&id).await?; - let entries = runtime.logs(&id, filter).await?; - let response_entries: Vec = - entries.iter().cloned().map(map_process_log_line).collect(); - let last_replay_seq = response_entries.last().map(|e| e.sequence).unwrap_or(0); + let entries = runtime.logs(&id, filter).await?; + let response_entries: Vec = + entries.iter().cloned().map(map_process_log_line).collect(); + if query.follow.unwrap_or(false) { + let rx = runtime.subscribe_logs(&id).await?; let replay_stream = stream::iter(response_entries.into_iter().map(|entry| { Ok::( axum::response::sse::Event::default() @@ -1517,9 +1515,6 @@ async fn get_v1_process_logs( async move { match item { Ok(line) => { - if line.sequence <= last_replay_seq { - return None; - } let entry = map_process_log_line(line); if process_log_matches(&entry, requested_stream_copy) { Some(Ok(axum::response::sse::Event::default() @@ -1544,10 +1539,6 @@ async fn get_v1_process_logs( return Ok(response.into_response()); } - let entries = runtime.logs(&id, filter).await?; - let response_entries: Vec = - entries.iter().cloned().map(map_process_log_line).collect(); - Ok(Json(ProcessLogsResponse { process_id: id, stream: requested_stream, @@ -1601,13 +1592,51 @@ async fn post_v1_process_input( Ok(Json(ProcessInputResponse { bytes_written })) } +/// Resize a process terminal. +/// +/// Sets the PTY window size (columns and rows) for a tty-mode process and +/// sends SIGWINCH so the child process can adapt. +#[utoipa::path( + post, + path = "/v1/processes/{id}/terminal/resize", + tag = "v1", + params( + ("id" = String, Path, description = "Process ID") + ), + request_body = ProcessTerminalResizeRequest, + responses( + (status = 200, description = "Resize accepted", body = ProcessTerminalResizeResponse), + (status = 400, description = "Invalid request", body = ProblemDetails), + (status = 404, description = "Unknown process", body = ProblemDetails), + (status = 409, description = "Not a terminal process", body = ProblemDetails), + (status = 501, description = "Process API unsupported on this platform", body = ProblemDetails) + ) +)] +async fn post_v1_process_terminal_resize( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + if !process_api_supported() { + return Err(process_api_not_supported().into()); + } + + state + .process_runtime() + .resize_terminal(&id, body.cols, body.rows) + .await?; + Ok(Json(ProcessTerminalResizeResponse { + cols: body.cols, + rows: body.rows, + })) +} + /// Open an interactive WebSocket terminal session. /// /// 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). Uses the `channel.k8s.io` binary subprotocol: -/// channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize, -/// and channel 255 close. +/// send custom headers). Streams raw PTY output as binary frames and accepts +/// JSON control frames for input, resize, and close. #[utoipa::path( get, path = "/v1/processes/{id}/terminal/ws", @@ -1643,16 +1672,23 @@ async fn get_v1_process_terminal_ws( } Ok(ws - .protocols([CHANNEL_K8S_IO_PROTOCOL]) .on_upgrade(move |socket| process_terminal_ws_session(socket, runtime, id)) .into_response()) } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TerminalResizePayload { - cols: u16, - rows: u16, +#[serde(tag = "type", rename_all = "camelCase")] +enum TerminalClientFrame { + Input { + data: String, + #[serde(default)] + encoding: Option, + }, + Resize { + cols: u16, + rows: u16, + }, + Close, } async fn process_terminal_ws_session( @@ -1660,7 +1696,7 @@ async fn process_terminal_ws_session( runtime: Arc, id: String, ) { - let _ = send_status_json( + let _ = send_ws_json( &mut socket, json!({ "type": "ready", @@ -1672,8 +1708,7 @@ async fn process_terminal_ws_session( let mut log_rx = match runtime.subscribe_logs(&id).await { Ok(rx) => rx, Err(err) => { - let _ = send_status_error(&mut socket, &err.to_string()).await; - let _ = send_close_signal(&mut socket).await; + let _ = send_ws_error(&mut socket, &err.to_string()).await; let _ = socket.close().await; return; } @@ -1684,57 +1719,43 @@ async fn process_terminal_ws_session( tokio::select! { ws_in = socket.recv() => { match ws_in { - Some(Ok(Message::Binary(bytes))) => { - let Some((&channel, payload)) = bytes.split_first() else { - let _ = send_status_error(&mut socket, "invalid terminal frame: missing channel byte").await; - continue; - }; - - match channel { - CH_STDIN => { - let input = payload.to_vec(); - let max_input = runtime.max_input_bytes().await; - if input.len() > max_input { - let _ = send_status_error(&mut socket, &format!("input payload exceeds maxInputBytesPerRequest ({max_input})")).await; - continue; - } - if let Err(err) = runtime.write_input(&id, &input).await { - let _ = send_status_error(&mut socket, &err.to_string()).await; - } - } - CH_RESIZE => { - let resize = match serde_json::from_slice::(payload) { - Ok(resize) => resize, + Some(Ok(Message::Binary(_))) => { + let _ = send_ws_error(&mut socket, "binary input is not supported; use text JSON frames").await; + } + Some(Ok(Message::Text(text))) => { + let parsed = serde_json::from_str::(&text); + match parsed { + Ok(TerminalClientFrame::Input { data, encoding }) => { + let input = match decode_input_bytes(&data, encoding.as_deref().unwrap_or("utf8")) { + Ok(input) => input, Err(err) => { - let _ = send_status_error(&mut socket, &format!("invalid resize payload: {err}")).await; + let _ = send_ws_error(&mut socket, &err.to_string()).await; continue; } }; - - if let Err(err) = runtime - .resize_terminal(&id, resize.cols, resize.rows) - .await - { - let _ = send_status_error(&mut socket, &err.to_string()).await; + let max_input = runtime.max_input_bytes().await; + if input.len() > max_input { + let _ = send_ws_error(&mut socket, &format!("input payload exceeds maxInputBytesPerRequest ({max_input})")).await; + continue; + } + if let Err(err) = runtime.write_input(&id, &input).await { + let _ = send_ws_error(&mut socket, &err.to_string()).await; } } - CH_CLOSE => { - let _ = send_close_signal(&mut socket).await; + Ok(TerminalClientFrame::Resize { cols, rows }) => { + if let Err(err) = runtime.resize_terminal(&id, cols, rows).await { + let _ = send_ws_error(&mut socket, &err.to_string()).await; + } + } + Ok(TerminalClientFrame::Close) => { let _ = socket.close().await; break; } - _ => { - let _ = send_status_error(&mut socket, &format!("unsupported terminal channel: {channel}")).await; + Err(err) => { + let _ = send_ws_error(&mut socket, &format!("invalid terminal frame: {err}")).await; } } } - Some(Ok(Message::Text(_))) => { - let _ = send_status_error( - &mut socket, - "text frames are not supported; use channel.k8s.io binary frames", - ) - .await; - } Some(Ok(Message::Ping(payload))) => { let _ = socket.send(Message::Pong(payload)).await; } @@ -1754,7 +1775,7 @@ async fn process_terminal_ws_session( use base64::Engine; BASE64_ENGINE.decode(&line.data).unwrap_or_default() }; - if send_channel_frame(&mut socket, CH_STDOUT, bytes).await.is_err() { + if socket.send(Message::Binary(bytes)).await.is_err() { break; } } @@ -1765,7 +1786,7 @@ async fn process_terminal_ws_session( _ = exit_poll.tick() => { if let Ok(snapshot) = runtime.snapshot(&id).await { if snapshot.status == ProcessStatus::Exited { - let _ = send_status_json( + let _ = send_ws_json( &mut socket, json!({ "type": "exit", @@ -1773,7 +1794,6 @@ async fn process_terminal_ws_session( }), ) .await; - let _ = send_close_signal(&mut socket).await; let _ = socket.close().await; break; } @@ -1783,30 +1803,17 @@ async fn process_terminal_ws_session( } } -async fn send_channel_frame( - socket: &mut WebSocket, - channel: u8, - payload: impl Into>, -) -> Result<(), ()> { - let mut frame = vec![channel]; - frame.extend(payload.into()); +async fn send_ws_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> { socket - .send(Message::Binary(frame.into())) + .send(Message::Text( + serde_json::to_string(&payload).map_err(|_| ())?, + )) .await .map_err(|_| ()) } -async fn send_status_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> { - send_channel_frame( - socket, - CH_STATUS, - serde_json::to_vec(&payload).map_err(|_| ())?, - ) - .await -} - -async fn send_status_error(socket: &mut WebSocket, message: &str) -> Result<(), ()> { - send_status_json( +async fn send_ws_error(socket: &mut WebSocket, message: &str) -> Result<(), ()> { + send_ws_json( socket, json!({ "type": "error", @@ -1816,10 +1823,6 @@ async fn send_status_error(socket: &mut WebSocket, message: &str) -> Result<(), .await } -async fn send_close_signal(socket: &mut WebSocket) -> Result<(), ()> { - send_channel_frame(socket, CH_CLOSE, Vec::::new()).await -} - #[utoipa::path( get, path = "/v1/config/mcp", diff --git a/server/packages/sandbox-agent/src/router/support.rs b/server/packages/sandbox-agent/src/router/support.rs index 21dded4..0e7a7b1 100644 --- a/server/packages/sandbox-agent/src/router/support.rs +++ b/server/packages/sandbox-agent/src/router/support.rs @@ -71,10 +71,7 @@ fn percent_decode(input: &str) -> String { let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() { - if let (Some(hi), Some(lo)) = ( - hex_nibble(bytes[i + 1]), - hex_nibble(bytes[i + 2]), - ) { + if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) { output.push((hi << 4) | lo); i += 3; continue; @@ -147,6 +144,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 +163,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 +180,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 +275,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 +283,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 } diff --git a/server/packages/sandbox-agent/src/router/types.rs b/server/packages/sandbox-agent/src/router/types.rs index 07b389d..6d40e2a 100644 --- a/server/packages/sandbox-agent/src/router/types.rs +++ b/server/packages/sandbox-agent/src/router/types.rs @@ -512,6 +512,20 @@ pub struct ProcessSignalQuery { pub wait_ms: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProcessTerminalResizeRequest { + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProcessTerminalResizeResponse { + pub cols: u16, + pub rows: u16, +} + #[derive(Debug, Clone, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ProcessWsQuery { diff --git a/server/packages/sandbox-agent/tests/v1_api.rs b/server/packages/sandbox-agent/tests/v1_api.rs index 3dbd5e7..9e5cc3b 100644 --- a/server/packages/sandbox-agent/tests/v1_api.rs +++ b/server/packages/sandbox-agent/tests/v1_api.rs @@ -65,8 +65,8 @@ impl LiveServer { let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let task = tokio::spawn(async move { - let server = axum::serve(listener, app.into_make_service()) - .with_graceful_shutdown(async { + let server = + axum::serve(listener, app.into_make_service()).with_graceful_shutdown(async { let _ = shutdown_rx.await; }); diff --git a/server/packages/sandbox-agent/tests/v1_api/processes.rs b/server/packages/sandbox-agent/tests/v1_api/processes.rs index 3b619bf..3c02029 100644 --- a/server/packages/sandbox-agent/tests/v1_api/processes.rs +++ b/server/packages/sandbox-agent/tests/v1_api/processes.rs @@ -3,17 +3,8 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use futures::{SinkExt, StreamExt}; use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::Message; -const CHANNEL_K8S_IO_PROTOCOL: &str = "channel.k8s.io"; -const CH_STDIN: u8 = 0; -const CH_STDOUT: u8 = 1; -const CH_STATUS: u8 = 3; -const CH_RESIZE: u8 = 4; -const CH_CLOSE: u8 = 255; - async fn wait_for_exited(test_app: &TestApp, process_id: &str) { for _ in 0..30 { let (status, _, body) = send_request( @@ -57,19 +48,6 @@ async fn recv_ws_message( .expect("websocket frame") } -fn make_channel_frame(channel: u8, payload: impl AsRef<[u8]>) -> Vec { - let payload = payload.as_ref(); - let mut frame = Vec::with_capacity(payload.len() + 1); - frame.push(channel); - frame.extend_from_slice(payload); - frame -} - -fn parse_channel_frame(bytes: &[u8]) -> (u8, &[u8]) { - let (&channel, payload) = bytes.split_first().expect("channel frame"); - (channel, payload) -} - #[tokio::test] async fn v1_processes_config_round_trip() { let test_app = TestApp::new(AuthConfig::disabled()); @@ -544,81 +522,54 @@ async fn v1_process_terminal_ws_e2e_is_deterministic() { let process_id = create_body["id"].as_str().expect("process id").to_string(); let ws_url = live_server.ws_url(&format!("/v1/processes/{process_id}/terminal/ws")); - let mut ws_request = ws_url.into_client_request().expect("ws request"); - ws_request.headers_mut().insert( - "Sec-WebSocket-Protocol", - HeaderValue::from_static(CHANNEL_K8S_IO_PROTOCOL), - ); - let (mut ws, response) = connect_async(ws_request).await.expect("connect websocket"); - assert_eq!( - response - .headers() - .get("Sec-WebSocket-Protocol") - .and_then(|value| value.to_str().ok()), - Some(CHANNEL_K8S_IO_PROTOCOL) - ); + let (mut ws, _) = connect_async(&ws_url).await.expect("connect websocket"); let ready = recv_ws_message(&mut ws).await; - let ready_bytes = ready.into_data(); - let (ready_channel, ready_payload) = parse_channel_frame(&ready_bytes); - assert_eq!(ready_channel, CH_STATUS); - let ready_payload: Value = serde_json::from_slice(ready_payload).expect("ready json"); + let ready_payload: Value = + serde_json::from_str(ready.to_text().expect("ready text frame")).expect("ready json"); assert_eq!(ready_payload["type"], "ready"); assert_eq!(ready_payload["processId"], process_id); - ws.send(Message::Binary( - make_channel_frame(CH_STDIN, b"hello from ws\n").into(), + ws.send(Message::Text( + json!({ + "type": "input", + "data": "hello from ws\n" + }) + .to_string(), )) .await .expect("send input frame"); - ws.send(Message::Binary( - make_channel_frame(CH_RESIZE, br#"{"cols":120,"rows":40}"#).into(), - )) - .await - .expect("send resize frame"); - - let mut saw_stdout = false; + let mut saw_binary_output = false; let mut saw_exit = false; - let mut saw_close = false; for _ in 0..10 { let frame = recv_ws_message(&mut ws).await; match frame { Message::Binary(bytes) => { - let (channel, payload) = parse_channel_frame(&bytes); - match channel { - CH_STDOUT => { - let text = String::from_utf8_lossy(payload); - if text.contains("got:hello from ws") { - saw_stdout = true; - } - } - CH_STATUS => { - let payload: Value = - serde_json::from_slice(payload).expect("ws status json"); - if payload["type"] == "exit" { - saw_exit = true; - } else { - assert_ne!(payload["type"], "error"); - } - } - CH_CLOSE => { - assert!(payload.is_empty(), "close channel payload must be empty"); - saw_close = true; - break; - } - other => panic!("unexpected websocket channel: {other}"), + let text = String::from_utf8_lossy(&bytes); + if text.contains("got:hello from ws") { + saw_binary_output = true; } } + Message::Text(text) => { + let payload: Value = serde_json::from_str(&text).expect("ws json"); + if payload["type"] == "exit" { + saw_exit = true; + break; + } + assert_ne!(payload["type"], "error"); + } Message::Close(_) => break, Message::Ping(_) | Message::Pong(_) => {} _ => {} } } - assert!(saw_stdout, "expected pty stdout over websocket"); - assert!(saw_exit, "expected exit status frame over websocket"); - assert!(saw_close, "expected close channel frame over websocket"); + assert!( + saw_binary_output, + "expected pty binary output over websocket" + ); + assert!(saw_exit, "expected exit control frame over websocket"); let _ = ws.close(None).await; @@ -668,38 +619,19 @@ async fn v1_process_terminal_ws_auth_e2e() { let auth_ws_url = live_server.ws_url(&format!( "/v1/processes/{process_id}/terminal/ws?access_token={token}" )); - let mut ws_request = auth_ws_url.into_client_request().expect("ws request"); - ws_request.headers_mut().insert( - "Sec-WebSocket-Protocol", - HeaderValue::from_static(CHANNEL_K8S_IO_PROTOCOL), - ); - let (mut ws, response) = connect_async(ws_request) + let (mut ws, _) = connect_async(&auth_ws_url) .await .expect("authenticated websocket handshake"); - assert_eq!( - response - .headers() - .get("Sec-WebSocket-Protocol") - .and_then(|value| value.to_str().ok()), - Some(CHANNEL_K8S_IO_PROTOCOL) - ); let ready = recv_ws_message(&mut ws).await; - let ready_bytes = ready.into_data(); - let (ready_channel, ready_payload) = parse_channel_frame(&ready_bytes); - assert_eq!(ready_channel, CH_STATUS); - let ready_payload: Value = serde_json::from_slice(ready_payload).expect("ready json"); + let ready_payload: Value = + serde_json::from_str(ready.to_text().expect("ready text frame")).expect("ready json"); assert_eq!(ready_payload["type"], "ready"); assert_eq!(ready_payload["processId"], process_id); let _ = ws - .send(Message::Binary(make_channel_frame(CH_CLOSE, []).into())) + .send(Message::Text(json!({ "type": "close" }).to_string())) .await; - let close = recv_ws_message(&mut ws).await; - let close_bytes = close.into_data(); - let (close_channel, close_payload) = parse_channel_frame(&close_bytes); - assert_eq!(close_channel, CH_CLOSE); - assert!(close_payload.is_empty()); let _ = ws.close(None).await; let kill_response = http