From ef3e811c94b792415f5a41ff3fadb17518772f9d Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 4 Feb 2026 13:43:05 -0800 Subject: [PATCH] feat: add opencode compatibility layer (#68) --- CLAUDE.md | 12 + Cargo.toml | 1 + docs/cli.mdx | 31 + docs/docs.json | 11 +- docs/opencode-compatibility.mdx | 108 + research/agents/openclaw.md | 553 + research/opencode-tmux-test.md | 54 + research/opencode-web-customization.md | 82 + .../artifacts/json-schema/opencode.json | 501 +- .../artifacts/openapi/opencode.json | 10933 ++++++++++++++++ resources/agent-schemas/src/opencode.ts | 34 +- server/packages/sandbox-agent/Cargo.toml | 5 + server/packages/sandbox-agent/src/lib.rs | 2 + server/packages/sandbox-agent/src/main.rs | 210 +- .../sandbox-agent/src/opencode_compat.rs | 4274 ++++++ server/packages/sandbox-agent/src/router.rs | 173 +- .../sandbox-agent/src/server_logs/mod.rs | 9 + .../sandbox-agent/src/server_logs/unix.rs | 103 + .../sandbox-agent/src/server_logs/windows.rs | 131 + .../tests/opencode-compat/README.md | 81 + .../tests/opencode-compat/events.test.ts | 148 + .../tests/opencode-compat/helpers/spawn.ts | 237 + .../tests/opencode-compat/messaging.test.ts | 170 + .../tests/opencode-compat/package.json | 18 + .../tests/opencode-compat/permissions.test.ts | 97 + .../tests/opencode-compat/questions.test.ts | 83 + .../tests/opencode-compat/session.test.ts | 148 + .../tests/opencode-compat/tools.test.ts | 85 + .../tests/opencode-compat/tsconfig.json | 17 + .../tests/opencode-compat/vitest.config.ts | 30 + .../sandbox-agent/tests/opencode_openapi.rs | 73 + .../src/agents/opencode.rs | 59 +- 32 files changed, 18163 insertions(+), 310 deletions(-) create mode 100644 docs/opencode-compatibility.mdx create mode 100644 research/agents/openclaw.md create mode 100644 research/opencode-tmux-test.md create mode 100644 research/opencode-web-customization.md create mode 100644 resources/agent-schemas/artifacts/openapi/opencode.json create mode 100644 server/packages/sandbox-agent/src/opencode_compat.rs create mode 100644 server/packages/sandbox-agent/src/server_logs/mod.rs create mode 100644 server/packages/sandbox-agent/src/server_logs/unix.rs create mode 100644 server/packages/sandbox-agent/src/server_logs/windows.rs create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/README.md create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/events.test.ts create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/helpers/spawn.ts create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/package.json create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/session.test.ts create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/tsconfig.json create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/vitest.config.ts create mode 100644 server/packages/sandbox-agent/tests/opencode_openapi.rs diff --git a/CLAUDE.md b/CLAUDE.md index 6c5ca0a..9f6a874 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,10 +64,22 @@ Universal schema guidance: - `sandbox-agent api sessions reject-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` - `sandbox-agent api sessions reply-permission` ↔ `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` +## OpenCode CLI (Experimental) + +`sandbox-agent opencode` starts a sandbox-agent server and attaches an OpenCode session (uses `/opencode`). + ## Post-Release Testing After cutting a release, verify the release works correctly. Run `/project:post-release-testing` to execute the testing agent. +## OpenCode Compatibility Tests + +The OpenCode compatibility suite lives at `server/packages/sandbox-agent/tests/opencode-compat` and validates the `@opencode-ai/sdk` against the `/opencode` API. Run it with: + +```bash +SANDBOX_AGENT_SKIP_INSPECTOR=1 pnpm --filter @sandbox-agent/opencode-compat-tests test +``` + ## Git Commits - Do not include any co-authors in commit messages (no `Co-Authored-By` lines) diff --git a/Cargo.toml b/Cargo.toml index a30a351..3907eee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ zip = { version = "0.6", default-features = false, features = ["deflate"] } url = "2.5" regress = "0.10" include_dir = "0.7" +base64 = "0.22" # Code generation (build deps) typify = "0.4" diff --git a/docs/cli.mdx b/docs/cli.mdx index 855bd44..6466cc7 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -29,6 +29,12 @@ sandbox-agent server [OPTIONS] sandbox-agent server --token "$TOKEN" --port 3000 ``` +Server logs are redirected to a daily log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/logs`). Override with `SANDBOX_AGENT_LOG_DIR`, or set `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr. + +HTTP request logging is enabled by default. Control it with: +- `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs +- `SANDBOX_AGENT_LOG_HTTP_HEADERS=1` to include request headers (Authorization is redacted) + --- ## Install Agent (Local) @@ -49,6 +55,31 @@ sandbox-agent install-agent claude --reinstall --- +## OpenCode (Experimental) + +Start a sandbox-agent server and attach an OpenCode session (uses `opencode attach`): + +```bash +sandbox-agent opencode [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-t, --token ` | - | Authentication token for all requests | +| `-n, --no-token` | - | Disable authentication (local dev only) | +| `-H, --host ` | `127.0.0.1` | Host to bind to | +| `-p, --port ` | `2468` | Port to bind to | +| `--session-title ` | - | Title for the OpenCode session | +| `--opencode-bin <PATH>` | - | Override `opencode` binary path | + +```bash +sandbox-agent opencode --token "$TOKEN" +``` + +Requires the `opencode` binary to be installed (or set `OPENCODE_BIN` / `--opencode-bin`). + +--- + ## Credentials ### Extract diff --git a/docs/docs.json b/docs/docs.json index 43eb67b..5e71ba9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -61,11 +61,12 @@ }, { "group": "Reference", - "pages": [ - "cli", - "inspector", - "session-transcript-schema", - "cors", + "pages": [ + "cli", + "opencode-compatibility", + "inspector", + "session-transcript-schema", + "cors", { "group": "AI", "pages": ["ai/skill", "ai/llms-txt"] diff --git a/docs/opencode-compatibility.mdx b/docs/opencode-compatibility.mdx new file mode 100644 index 0000000..563d8d6 --- /dev/null +++ b/docs/opencode-compatibility.mdx @@ -0,0 +1,108 @@ +--- +title: "OpenCode Compatibility" +description: "OpenCode endpoint coverage for /opencode." +--- + +Sandbox Agent exposes OpenCode-compatible endpoints under `/opencode`. +Authentication matches `/v1`: if a token is configured, requests must include `Authorization: Bearer <token>`. + +| Method | Path | Status | Notes | Tests | +|---|---|---|---|---| +| GET | /agent | Stubbed | Single stub agent entry. | E2E: openapi-coverage | +| DELETE | /auth/{providerID} | Stubbed | | E2E: openapi-coverage | +| PUT | /auth/{providerID} | Stubbed | | E2E: openapi-coverage | +| GET | /command | Stubbed | | E2E: openapi-coverage | +| GET | /config | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage | +| PATCH | /config | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage | +| GET | /config/providers | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage | +| GET | /event | SSE stub | Emits compat events for session/message/pty updates only. | E2E: openapi-coverage, events | +| GET | /experimental/resource | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| GET | /experimental/tool | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| GET | /experimental/tool/ids | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| DELETE | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| GET | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| POST | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| POST | /experimental/worktree/reset | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| GET | /file | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage | +| GET | /file/content | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage | +| GET | /file/status | Stubbed | Returns empty lists/content; no filesystem access. | E2E: openapi-coverage | +| GET | /find | Stubbed | Returns empty results. | E2E: openapi-coverage | +| GET | /find/file | Stubbed | Returns empty results. | E2E: openapi-coverage | +| GET | /find/symbol | Stubbed | Returns empty results. | E2E: openapi-coverage | +| GET | /formatter | Stubbed | | E2E: openapi-coverage | +| GET | /global/config | Stubbed | | E2E: openapi-coverage | +| PATCH | /global/config | Stubbed | | E2E: openapi-coverage | +| POST | /global/dispose | Stubbed | | E2E: openapi-coverage | +| GET | /global/event | SSE stub | Wraps compat events in GlobalEvent; no external sources. | E2E: openapi-coverage, events | +| GET | /global/health | Stubbed | | E2E: openapi-coverage | +| POST | /instance/dispose | Stubbed | | E2E: openapi-coverage | +| POST | /log | Stubbed | | E2E: openapi-coverage | +| GET | /lsp | Stubbed | | E2E: openapi-coverage | +| GET | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| POST | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| DELETE | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| POST | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| POST | /mcp/{name}/auth/authenticate | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| POST | /mcp/{name}/auth/callback | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| POST | /mcp/{name}/connect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| POST | /mcp/{name}/disconnect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| GET | /path | Derived stub | | E2E: openapi-coverage | +| GET | /permission | Stubbed | | E2E: openapi-coverage, permissions | +| POST | /permission/{requestID}/reply | Stubbed | | E2E: openapi-coverage, permissions | +| GET | /project | Derived stub | | E2E: openapi-coverage | +| PATCH | /project/{projectID} | Derived stub | | E2E: openapi-coverage | +| GET | /project/current | Derived stub | | E2E: openapi-coverage | +| GET | /provider | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage | +| POST | /provider/{providerID}/oauth/authorize | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage | +| POST | /provider/{providerID}/oauth/callback | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage | +| GET | /provider/auth | Stubbed | Returns empty provider metadata. | E2E: openapi-coverage | +| GET | /pty | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage | +| POST | /pty | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage | +| DELETE | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage | +| GET | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage | +| PUT | /pty/{ptyID} | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage | +| GET | /pty/{ptyID}/connect | Stateful stub | In-memory PTY records; no real PTY process. | E2E: openapi-coverage | +| GET | /question | Stubbed | | E2E: openapi-coverage, permissions | +| POST | /question/{requestID}/reject | Stubbed | | E2E: openapi-coverage, permissions | +| POST | /question/{requestID}/reply | Stubbed | | E2E: openapi-coverage, permissions | +| GET | /session | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session | +| POST | /session | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session | +| DELETE | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session | +| GET | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session | +| PATCH | /session/{sessionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, session | +| POST | /session/{sessionID}/abort | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, messaging | +| GET | /session/{sessionID}/children | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/command | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| GET | /session/{sessionID}/diff | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/fork | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/init | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| GET | /session/{sessionID}/message | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging, events | +| POST | /session/{sessionID}/message | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging, events | +| GET | /session/{sessionID}/message/{messageID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging | +| DELETE | /session/{sessionID}/message/{messageID}/part/{partID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging | +| PATCH | /session/{sessionID}/message/{messageID}/part/{partID} | Stateful stub | In-memory messages; assistant replies are stubbed. | E2E: openapi-coverage, messaging | +| POST | /session/{sessionID}/permissions/{permissionID} | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/prompt_async | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, messaging | +| POST | /session/{sessionID}/revert | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| DELETE | /session/{sessionID}/share | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/share | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/shell | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/summarize | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| GET | /session/{sessionID}/todo | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| POST | /session/{sessionID}/unrevert | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage | +| GET | /session/status | Stateful stub | In-memory session store; not persisted or mapped to /v1. | E2E: openapi-coverage, events | +| GET | /skill | Stubbed | Returns empty skills list. | E2E: openapi-coverage | +| POST | /tui/append-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/clear-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| GET | /tui/control/next | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/control/response | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/execute-command | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/open-help | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/open-models | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/open-sessions | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/open-themes | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/publish | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/select-session | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/show-toast | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| POST | /tui/submit-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | +| GET | /vcs | Derived stub | | E2E: openapi-coverage | \ No newline at end of file diff --git a/research/agents/openclaw.md b/research/agents/openclaw.md new file mode 100644 index 0000000..0a9e5ba --- /dev/null +++ b/research/agents/openclaw.md @@ -0,0 +1,553 @@ +# OpenClaw (formerly Clawdbot) Research + +Research notes on OpenClaw's architecture, API, and automation patterns for integration with sandbox-agent. + +## Overview + +- **Provider**: Multi-provider (Anthropic, OpenAI, etc. via Pi agent) +- **Execution Method**: WebSocket Gateway + HTTP APIs +- **Session Persistence**: Session Key (string) + Session ID (UUID) +- **SDK**: No official SDK - uses WebSocket/HTTP protocol directly +- **Binary**: `clawdbot` (npm global install or local) +- **Default Port**: 18789 (WebSocket + HTTP multiplex) + +## Architecture + +OpenClaw is architected differently from other coding agents (Claude Code, Codex, OpenCode, Amp): + +``` +┌─────────────────────────────────────┐ +│ Gateway Service │ ws://127.0.0.1:18789 +│ (long-running daemon) │ http://127.0.0.1:18789 +│ │ +│ ┌─────────────────────────────┐ │ +│ │ Pi Agent (embedded RPC) │ │ +│ │ - Tool execution │ │ +│ │ - Model routing │ │ +│ │ - Session management │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ + │ + ├── WebSocket (full control plane) + ├── HTTP /v1/chat/completions (OpenAI-compatible) + ├── HTTP /v1/responses (OpenResponses-compatible) + ├── HTTP /tools/invoke (single tool invocation) + └── HTTP /hooks/agent (webhook triggers) +``` + +**Key Difference**: OpenClaw runs as a **daemon** that owns the agent runtime. Other agents (Claude, Codex, Amp) spawn a subprocess per turn. OpenClaw is more similar to OpenCode's server model but with a persistent gateway. + +## Automation Methods (Priority Order) + +### 1. WebSocket Gateway Protocol (Recommended) + +Full-featured bidirectional control with streaming events. + +#### Connection Handshake + +```typescript +// Connect to Gateway +const ws = new WebSocket("ws://127.0.0.1:18789"); + +// First frame MUST be connect request +ws.send(JSON.stringify({ + type: "req", + id: "1", + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "gateway-client", // or custom client id + version: "1.0.0", + platform: "linux", + mode: "backend" + }, + role: "operator", + scopes: ["operator.admin"], + caps: [], + auth: { token: "YOUR_GATEWAY_TOKEN" } + } +})); + +// Expect hello-ok response +// { type: "res", id: "1", ok: true, payload: { type: "hello-ok", ... } } +``` + +#### Agent Request + +```typescript +// Send agent turn request +const runId = crypto.randomUUID(); +ws.send(JSON.stringify({ + type: "req", + id: runId, + method: "agent", + params: { + message: "Your prompt here", + idempotencyKey: runId, + sessionKey: "agent:main:main", // or custom session key + thinking: "low", // optional: low|medium|high + deliver: false, // don't send to messaging channel + timeout: 300000 // 5 minute timeout + } +})); +``` + +#### Response Flow (Two-Stage) + +```typescript +// Stage 1: Immediate ack +// { type: "res", id: "...", ok: true, payload: { runId, status: "accepted", acceptedAt: 1234567890 } } + +// Stage 2: Streaming events +// { type: "event", event: "agent", payload: { runId, seq: 1, stream: "output", data: {...} } } +// { type: "event", event: "agent", payload: { runId, seq: 2, stream: "tool", data: {...} } } +// ... + +// Stage 3: Final response (same id as request) +// { type: "res", id: "...", ok: true, payload: { runId, status: "ok", summary: "completed", result: {...} } } +``` + +### 2. OpenAI-Compatible HTTP API + +For simple integration with tools expecting OpenAI Chat Completions. + +**Enable in config:** +```json5 +{ + gateway: { + http: { + endpoints: { + chatCompletions: { enabled: true } + } + } + } +} +``` + +**Request:** +```bash +curl -X POST http://127.0.0.1:18789/v1/chat/completions \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "clawdbot:main", + "messages": [{"role": "user", "content": "Hello"}], + "stream": true + }' +``` + +**Model Format:** +- `model: "clawdbot:<agentId>"` (e.g., `"clawdbot:main"`) +- `model: "agent:<agentId>"` (alias) + +### 3. OpenResponses HTTP API + +For clients that speak OpenResponses (item-based input, function tools). + +**Enable in config:** +```json5 +{ + gateway: { + http: { + endpoints: { + responses: { enabled: true } + } + } + } +} +``` + +**Request:** +```bash +curl -X POST http://127.0.0.1:18789/v1/responses \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "clawdbot:main", + "input": "Hello", + "stream": true + }' +``` + +### 4. Webhooks (Fire-and-Forget) + +For event-driven automation without maintaining a connection. + +**Enable in config:** +```json5 +{ + hooks: { + enabled: true, + token: "webhook-secret", + path: "/hooks" + } +} +``` + +**Request:** +```bash +curl -X POST http://127.0.0.1:18789/hooks/agent \ + -H "Authorization: Bearer webhook-secret" \ + -H "Content-Type: application/json" \ + -d '{ + "message": "Run this task", + "name": "Automation", + "sessionKey": "hook:automation:task-123", + "deliver": false, + "timeoutSeconds": 120 + }' +``` + +**Response:** `202 Accepted` (async run started) + +### 5. CLI Subprocess + +For simple one-off automation (similar to Claude Code pattern). + +```bash +clawdbot agent --message "Your prompt" --session-key "automation:task" +``` + +## Session Management + +### Session Key Format + +``` +agent:<agentId>:<sessionType> +agent:main:main # Main agent, main session +agent:main:subagent:abc # Subagent session +agent:beta:main # Beta agent, main session +hook:email:msg-123 # Webhook-spawned session +global # Legacy global session +``` + +### Session Operations (WebSocket) + +```typescript +// List sessions +{ type: "req", id: "...", method: "sessions.list", params: { limit: 50, activeMinutes: 120 } } + +// Resolve session info +{ type: "req", id: "...", method: "sessions.resolve", params: { key: "agent:main:main" } } + +// Patch session settings +{ type: "req", id: "...", method: "sessions.patch", params: { + key: "agent:main:main", + thinkingLevel: "medium", + model: "anthropic/claude-sonnet-4-20250514" +}} + +// Reset session (clear history) +{ type: "req", id: "...", method: "sessions.reset", params: { key: "agent:main:main" } } + +// Delete session +{ type: "req", id: "...", method: "sessions.delete", params: { key: "agent:main:main" } } + +// Compact session (summarize history) +{ type: "req", id: "...", method: "sessions.compact", params: { key: "agent:main:main" } } +``` + +### Session CLI + +```bash +clawdbot sessions # List sessions +clawdbot sessions --active 120 # Active in last 2 hours +clawdbot sessions --json # JSON output +``` + +## Streaming Events + +### Event Format + +```typescript +interface AgentEvent { + runId: string; // Correlates to request + seq: number; // Monotonically increasing per run + stream: string; // Event category + ts: number; // Unix timestamp (ms) + data: Record<string, unknown>; // Event-specific payload +} +``` + +### Stream Types + +| Stream | Description | +|--------|-------------| +| `output` | Text output chunks | +| `tool` | Tool invocation/result | +| `thinking` | Extended thinking content | +| `status` | Run status changes | +| `error` | Error information | + +### Event Categories + +| Event Type | Payload | +|------------|---------| +| `assistant.delta` | `{ text: "..." }` | +| `tool.start` | `{ name: "Read", input: {...} }` | +| `tool.result` | `{ name: "Read", result: "..." }` | +| `thinking.delta` | `{ text: "..." }` | +| `run.complete` | `{ summary: "..." }` | +| `run.error` | `{ error: "..." }` | + +## Token Usage / Cost Tracking + +OpenClaw tracks tokens per response and supports cost estimation. + +### In-Chat Commands + +``` +/status # Session model, context usage, last response tokens, estimated cost +/usage off|tokens|full # Toggle per-response usage footer +/usage cost # Local cost summary from session logs +``` + +### Configuration + +Token costs are configured per model: +```json5 +{ + models: { + providers: { + anthropic: { + models: [{ + id: "claude-sonnet-4-20250514", + cost: { + input: 3.00, // USD per 1M tokens + output: 15.00, + cacheRead: 0.30, + cacheWrite: 3.75 + } + }] + } + } + } +} +``` + +### Programmatic Access + +Token usage is included in agent response payloads: +```typescript +// In final response or streaming events +{ + usage: { + inputTokens: 1234, + outputTokens: 567, + cacheReadTokens: 890, + cacheWriteTokens: 123 + } +} +``` + +## Authentication + +### Gateway Token + +```bash +# Environment variable +CLAWDBOT_GATEWAY_TOKEN=your-secret-token + +# Or config file +{ + gateway: { + auth: { + mode: "token", + token: "your-secret-token" + } + } +} +``` + +### HTTP Requests + +``` +Authorization: Bearer YOUR_TOKEN +``` + +### WebSocket Connect + +```typescript +{ + params: { + auth: { token: "YOUR_TOKEN" } + } +} +``` + +## Status Sync + +### Health Check + +```typescript +// WebSocket +{ type: "req", id: "...", method: "health", params: {} } + +// HTTP +curl http://127.0.0.1:18789/health # Basic health +clawdbot health --json # Detailed health +``` + +### Status Response + +```typescript +{ + ok: boolean; + linkedChannel?: string; + models?: { available: string[] }; + agents?: { configured: string[] }; + presence?: PresenceEntry[]; + uptimeMs?: number; +} +``` + +### Presence Events + +The gateway pushes presence updates to all connected clients: +```typescript +// Event +{ type: "event", event: "presence", payload: { entries: [...], stateVersion: {...} } } +``` + +## Comparison with Other Agents + +| Aspect | OpenClaw | Claude Code | Codex | OpenCode | Amp | +|--------|----------|-------------|-------|----------|-----| +| **Process Model** | Daemon | Subprocess | Server | Server | Subprocess | +| **Protocol** | WebSocket + HTTP | CLI JSONL | JSON-RPC stdio | HTTP + SSE | CLI JSONL | +| **Session Resume** | Session Key | `--resume` | Thread ID | Session ID | `--continue` | +| **Multi-Turn** | Same session key | Same session ID | Same thread | Same session | Same session | +| **Streaming** | WS events + SSE | JSONL | Notifications | SSE | JSONL | +| **HITL** | No | No (headless) | No (SDK) | Yes (SSE) | No | +| **SDK** | None (protocol) | None (CLI) | Yes | Yes | Closed | + +### Key Differences + +1. **Daemon Model**: OpenClaw runs as a persistent gateway service, not a per-turn subprocess +2. **Multi-Protocol**: Supports WebSocket, OpenAI-compatible HTTP, OpenResponses, and webhooks +3. **Channel Integration**: Built-in WhatsApp/Telegram/Discord/iMessage support +4. **Node System**: Mobile/desktop nodes can connect for camera, canvas, location, etc. +5. **No HITL**: Like Claude/Codex/Amp, permissions are configured upfront, not interactive + +## Integration Patterns for sandbox-agent + +### Recommended: Persistent WebSocket Connection + +```typescript +class OpenClawDriver { + private ws: WebSocket; + private pending = new Map<string, { resolve, reject }>(); + + async connect(url: string, token: string) { + this.ws = new WebSocket(url); + await this.handshake(token); + this.ws.on("message", (data) => this.handleMessage(JSON.parse(data))); + } + + async runAgent(params: { + message: string; + sessionKey?: string; + thinking?: string; + }): Promise<AgentResult> { + const runId = crypto.randomUUID(); + const events: AgentEvent[] = []; + + return new Promise((resolve, reject) => { + this.pending.set(runId, { resolve, reject, events }); + this.ws.send(JSON.stringify({ + type: "req", + id: runId, + method: "agent", + params: { + message: params.message, + sessionKey: params.sessionKey ?? "agent:main:main", + thinking: params.thinking, + deliver: false, + idempotencyKey: runId + } + })); + }); + } + + private handleMessage(frame: GatewayFrame) { + if (frame.type === "event" && frame.event === "agent") { + const pending = this.pending.get(frame.payload.runId); + if (pending) pending.events.push(frame.payload); + } else if (frame.type === "res") { + const pending = this.pending.get(frame.id); + if (pending && frame.payload?.status === "ok") { + pending.resolve({ result: frame.payload, events: pending.events }); + this.pending.delete(frame.id); + } else if (pending && frame.payload?.status === "error") { + pending.reject(new Error(frame.payload.summary)); + this.pending.delete(frame.id); + } + // Ignore "accepted" acks + } + } +} +``` + +### Alternative: HTTP API (Simpler) + +```typescript +async function runOpenClawPrompt(prompt: string, sessionKey?: string) { + const response = await fetch("http://127.0.0.1:18789/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.CLAWDBOT_GATEWAY_TOKEN}`, + "Content-Type": "application/json", + "x-clawdbot-session-key": sessionKey ?? "automation:sandbox" + }, + body: JSON.stringify({ + model: "clawdbot:main", + messages: [{ role: "user", content: prompt }], + stream: false + }) + }); + return response.json(); +} +``` + +## Configuration for sandbox-agent Integration + +Recommended config for automated use: + +```json5 +{ + gateway: { + port: 18789, + auth: { + mode: "token", + token: "${CLAWDBOT_GATEWAY_TOKEN}" + }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + responses: { enabled: true } + } + } + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4-20250514" + }, + thinking: { level: "low" }, + workspace: "${HOME}/workspace" + } + } +} +``` + +## Notes + +- OpenClaw is significantly more complex than other agents due to its gateway architecture +- The multi-protocol support (WS, OpenAI, OpenResponses, webhooks) provides flexibility +- Session management is richer (labels, spawn tracking, model/thinking overrides) +- No SDK means direct protocol implementation is required +- The daemon model means connection lifecycle management is important (reconnects, etc.) +- Agent responses are two-stage: immediate ack + final result (handle both) +- Tool policy filtering is configurable per agent/session/group diff --git a/research/opencode-tmux-test.md b/research/opencode-tmux-test.md new file mode 100644 index 0000000..ff553c0 --- /dev/null +++ b/research/opencode-tmux-test.md @@ -0,0 +1,54 @@ +# OpenCode TUI Test Plan (Tmux) + +This plan captures OpenCode TUI output and sends input via tmux so we can validate `/opencode` end-to-end. + +## Prereqs +- `opencode` installed and on PATH. +- `tmux` installed (e.g., `/home/linuxbrew/.linuxbrew/bin/tmux`). +- Local sandbox-agent binary built. + +## Environment +- `SANDBOX_AGENT_LOG_DIR=/path` to set server log dir +- `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr +- `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs +- `SANDBOX_AGENT_LOG_HTTP_HEADERS=1` to include request headers (Authorization redacted) +- `RUST_LOG=...` for trace filtering + +## Steps +1. Build and run the server using the local binary: + ```bash + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo build -p sandbox-agent + SANDBOX_AGENT_LOG_HTTP_HEADERS=1 ./target/debug/sandbox-agent server \ + --host 127.0.0.1 --port 2468 --token "$TOKEN" + ``` +2. Create a session via the OpenCode API: + ```bash + SESSION_JSON=$(curl -sS -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' \ + http://127.0.0.1:2468/opencode/session) + SESSION_ID=$(node -e "const v=JSON.parse(process.env.SESSION_JSON||'{}');process.stdout.write(v.id||'');") + ``` +3. Start the OpenCode TUI in tmux: + ```bash + tmux new-session -d -s opencode \ + "opencode attach http://127.0.0.1:2468/opencode --session $SESSION_ID --password $TOKEN" + ``` +4. Send a prompt: + ```bash + tmux send-keys -t opencode:0.0 "hello" C-m + ``` +5. Capture output: + ```bash + tmux capture-pane -pt opencode:0.0 -S -200 > /tmp/opencode-screen.txt + ``` +6. Inspect server logs for requests: + ```bash + tail -n 200 ~/.local/share/sandbox-agent/logs/log-$(date +%m-%d-%y) + ``` +7. Repeat after adjusting `/opencode` stubs until the TUI displays responses. + +## Notes +- Tmux captures terminal output only. GUI outputs require screenshots or logs. +- If OpenCode connects to another host/port, logs will show no requests. +- If the prompt stays in the input box, use `C-m` to submit (plain `Enter` may not trigger send in tmux). diff --git a/research/opencode-web-customization.md b/research/opencode-web-customization.md new file mode 100644 index 0000000..0ba0ed9 --- /dev/null +++ b/research/opencode-web-customization.md @@ -0,0 +1,82 @@ +# OpenCode Web Customization & Local Run Notes + +## Local Web UI (pointing at `/opencode`) + +This uses the OpenCode web app from `~/misc/opencode/packages/app` and points it at the +Sandbox Agent OpenCode-compatible API. The OpenCode JS SDK emits **absolute** paths +(`"/global/event"`, `"/session/:id/message"`, etc.), so any base URL path is discarded. +To keep the UI working, sandbox-agent now exposes the OpenCode router at both +`/opencode/*` and the root (`/global/*`, `/session/*`, ...). + +### 1) Start sandbox-agent (OpenCode compat) + +```bash +cd /home/nathan/sandbox-agent.feat-opencode-compat +SANDBOX_AGENT_SKIP_INSPECTOR=1 SANDBOX_AGENT_LOG_STDOUT=1 \ + ./target/debug/sandbox-agent server --no-token --host 127.0.0.1 --port 2468 \ + --cors-allow-origin http://127.0.0.1:5173 \ + > /tmp/sandbox-agent-opencode.log 2>&1 & +``` + +Logs: + +```bash +tail -f /tmp/sandbox-agent-opencode.log +``` + +### 2) Start OpenCode web app (dev) + +```bash +cd /home/nathan/misc/opencode/packages/app +VITE_OPENCODE_SERVER_HOST=127.0.0.1 VITE_OPENCODE_SERVER_PORT=2468 \ + /home/nathan/.bun/bin/bun run dev -- --host 127.0.0.1 --port 5173 \ + > /tmp/opencode-web.log 2>&1 & +``` + +Logs: + +```bash +tail -f /tmp/opencode-web.log +``` + +### 3) Open the UI + +``` +http://127.0.0.1:5173/ +``` + +The app should connect to `http://127.0.0.1:2468` by default in dev (via +`VITE_OPENCODE_SERVER_HOST/PORT`). If you see a “Could not connect to server” +error, verify the sandbox-agent process is running and reachable on port 2468. + +### Notes + +- The web UI uses `VITE_OPENCODE_SERVER_HOST` and `VITE_OPENCODE_SERVER_PORT` to + pick the OpenCode server in dev mode (see `packages/app/src/app.tsx`). +- When running in production, the app defaults to `window.location.origin` for + the server URL. If you need a different target, you must configure it via the + in-app “Switch server” dialog or change the build config. +- If you see a connect error in the web app, check CORS. By default, sandbox-agent + allows no origins. You must pass `--cors-allow-origin` for the dev server URL. +- The OpenCode provider list now exposes a `sandbox-agent` provider with models + for each agent (defaulting to `mock`). Use the provider/model selector in the UI + to pick the backing agent instead of environment variables. + +## Dev Server Learnings (Feb 4, 2026) + +- The browser **cannot** reach `http://127.0.0.1:2468` unless the web UI is on the + same machine. If the UI is loaded from `http://100.94.102.49:5173`, the server + must be reachable at `http://100.94.102.49:2468`. +- The OpenCode JS SDK uses absolute paths, so a base URL path (like + `http://host:port/opencode`) is ignored. This means the server must expose + OpenCode routes at the **root** (`/global/*`, `/session/*`, ...), even if it + also exposes them under `/opencode/*`. +- CORS must allow the UI origin. Example: + ```bash + ./target/debug/sandbox-agent server --no-token --host 0.0.0.0 --port 2468 \ + --cors-allow-origin http://100.94.102.49:5173 + ``` +- Binding the dev servers to `0.0.0.0` is required for remote access. Verify + `ss -ltnp | grep ':2468'` and `ss -ltnp | grep ':5173'`. +- If the UI throws “No default model found”, it usually means the `/provider` + response lacks a providerID → modelID default mapping for a connected provider. diff --git a/resources/agent-schemas/artifacts/json-schema/opencode.json b/resources/agent-schemas/artifacts/json-schema/opencode.json index 82a8235..7086df6 100644 --- a/resources/agent-schemas/artifacts/json-schema/opencode.json +++ b/resources/agent-schemas/artifacts/json-schema/opencode.json @@ -282,6 +282,14 @@ }, "deletions": { "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] } }, "required": [ @@ -777,6 +785,60 @@ "text" ] }, + "SubtaskPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "subtask" + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + "command": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "prompt", + "description", + "agent" + ] + }, "ReasoningPart": { "type": "object", "properties": { @@ -1541,58 +1603,7 @@ "$ref": "#/definitions/TextPart" }, { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "sessionID": { - "type": "string" - }, - "messageID": { - "type": "string" - }, - "type": { - "type": "string", - "const": "subtask" - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": [ - "providerID", - "modelID" - ] - }, - "command": { - "type": "string" - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ] + "$ref": "#/definitions/SubtaskPart" }, { "$ref": "#/definitions/ReasoningPart" @@ -3085,55 +3096,6 @@ "payload" ] }, - "BadRequestError": { - "type": "object", - "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "success": { - "type": "boolean", - "const": false - } - }, - "required": [ - "data", - "errors", - "success" - ] - }, - "NotFoundError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "const": "NotFoundError" - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ] - } - }, - "required": [ - "name", - "data" - ] - }, "KeybindsConfig": { "description": "Custom keybind configurations", "type": "object", @@ -3634,6 +3596,10 @@ "description": "Enable mDNS service discovery", "type": "boolean" }, + "mdnsDomain": { + "description": "Custom domain name for mDNS service (default: opencode.local)", + "type": "string" + }, "cors": { "description": "Additional domains to allow for CORS", "type": "array", @@ -3729,6 +3695,9 @@ }, "doom_loop": { "$ref": "#/definitions/PermissionActionConfig" + }, + "skill": { + "$ref": "#/definitions/PermissionRuleConfig" } }, "additionalProperties": { @@ -3746,6 +3715,10 @@ "model": { "type": "string" }, + "variant": { + "description": "Default model variant for this agent (applies only when using the agent's configured model).", + "type": "string" + }, "temperature": { "type": "number" }, @@ -3792,9 +3765,25 @@ "additionalProperties": {} }, "color": { - "description": "Hex color code for the agent (e.g., #FF5733)", - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" + "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "string", + "enum": [ + "primary", + "secondary", + "accent", + "success", + "warning", + "error", + "info" + ] + } + ] }, "steps": { "description": "Maximum number of agentic iterations before forcing text-only response", @@ -4296,6 +4285,19 @@ ] } }, + "skills": { + "description": "Additional skill folder paths", + "type": "object", + "properties": { + "paths": { + "description": "Additional paths to skill folders", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "watcher": { "type": "object", "properties": { @@ -4618,73 +4620,6 @@ "experimental": { "type": "object", "properties": { - "hook": { - "type": "object", - "properties": { - "file_edited": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "properties": { - "command": { - "type": "array", - "items": { - "type": "string" - } - }, - "environment": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "command" - ] - } - } - }, - "session_completed": { - "type": "array", - "items": { - "type": "object", - "properties": { - "command": { - "type": "array", - "items": { - "type": "string" - } - }, - "environment": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "command" - ] - } - } - } - }, - "chatMaxRetries": { - "description": "Number of retries for chat completions on failure", - "type": "number" - }, "disable_paste_summary": { "type": "boolean" }, @@ -4718,6 +4653,134 @@ }, "additionalProperties": false }, + "BadRequestError": { + "type": "object", + "properties": { + "data": {}, + "errors": { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "success": { + "type": "boolean", + "const": false + } + }, + "required": [ + "data", + "errors", + "success" + ] + }, + "OAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" + } + }, + "required": [ + "type", + "refresh", + "access", + "expires" + ] + }, + "ApiAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "api" + }, + "key": { + "type": "string" + } + }, + "required": [ + "type", + "key" + ] + }, + "WellKnownAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "wellknown" + }, + "key": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "type", + "key", + "token" + ] + }, + "Auth": { + "anyOf": [ + { + "$ref": "#/definitions/OAuth" + }, + { + "$ref": "#/definitions/ApiAuth" + }, + { + "$ref": "#/definitions/WellKnownAuth" + } + ] + }, + "NotFoundError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "NotFoundError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "name", + "data" + ] + }, "Model": { "type": "object", "properties": { @@ -5431,7 +5494,10 @@ "properties": { "type": { "type": "string", - "const": "text" + "enum": [ + "text", + "binary" + ] }, "content": { "type": "string" @@ -5682,8 +5748,13 @@ "model": { "type": "string" }, - "mcp": { - "type": "boolean" + "source": { + "type": "string", + "enum": [ + "command", + "mcp", + "skill" + ] }, "template": { "anyOf": [ @@ -5761,6 +5832,9 @@ "providerID" ] }, + "variant": { + "type": "string" + }, "prompt": { "type": "string" }, @@ -5837,85 +5911,6 @@ "extensions", "enabled" ] - }, - "OAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "oauth" - }, - "refresh": { - "type": "string" - }, - "access": { - "type": "string" - }, - "expires": { - "type": "number" - }, - "accountId": { - "type": "string" - }, - "enterpriseUrl": { - "type": "string" - } - }, - "required": [ - "type", - "refresh", - "access", - "expires" - ] - }, - "ApiAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "api" - }, - "key": { - "type": "string" - } - }, - "required": [ - "type", - "key" - ] - }, - "WellKnownAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "wellknown" - }, - "key": { - "type": "string" - }, - "token": { - "type": "string" - } - }, - "required": [ - "type", - "key", - "token" - ] - }, - "Auth": { - "anyOf": [ - { - "$ref": "#/definitions/OAuth" - }, - { - "$ref": "#/definitions/ApiAuth" - }, - { - "$ref": "#/definitions/WellKnownAuth" - } - ] } } } \ No newline at end of file diff --git a/resources/agent-schemas/artifacts/openapi/opencode.json b/resources/agent-schemas/artifacts/openapi/opencode.json new file mode 100644 index 0000000..3c70324 --- /dev/null +++ b/resources/agent-schemas/artifacts/openapi/opencode.json @@ -0,0 +1,10933 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "opencode", + "description": "opencode api", + "version": "1.0.0" + }, + "paths": { + "/global/health": { + "get": { + "operationId": "global.health", + "summary": "Get health", + "description": "Get health information about the OpenCode server.", + "responses": { + "200": { + "description": "Health information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "const": true + }, + "version": { + "type": "string" + } + }, + "required": ["healthy", "version"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.health({\n ...\n})" + } + ] + } + }, + "/global/event": { + "get": { + "operationId": "global.event", + "summary": "Get global events", + "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "responses": { + "200": { + "description": "Event stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/GlobalEvent" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.event({\n ...\n})" + } + ] + } + }, + "/global/config": { + "get": { + "operationId": "global.config.get", + "summary": "Get global configuration", + "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "responses": { + "200": { + "description": "Get global config info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.config.get({\n ...\n})" + } + ] + }, + "patch": { + "operationId": "global.config.update", + "summary": "Update global configuration", + "description": "Update global OpenCode configuration settings and preferences.", + "responses": { + "200": { + "description": "Successfully updated global config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.config.update({\n ...\n})" + } + ] + } + }, + "/global/dispose": { + "post": { + "operationId": "global.dispose", + "summary": "Dispose instance", + "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "responses": { + "200": { + "description": "Global disposed", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.dispose({\n ...\n})" + } + ] + } + }, + "/auth/{providerID}": { + "put": { + "operationId": "auth.set", + "summary": "Set auth credentials", + "description": "Set authentication credentials", + "responses": { + "200": { + "description": "Successfully set authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Auth" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "auth.remove", + "summary": "Remove auth credentials", + "description": "Remove authentication credentials", + "responses": { + "200": { + "description": "Successfully removed authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + } + ] + } + }, + "/project": { + "get": { + "operationId": "project.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List all projects", + "description": "Get a list of projects that have been opened with OpenCode.", + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + } + ] + } + }, + "/project/current": { + "get": { + "operationId": "project.current", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get current project", + "description": "Retrieve the currently active project that OpenCode is working with.", + "responses": { + "200": { + "description": "Current project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + } + ] + } + }, + "/project/{projectID}": { + "patch": { + "operationId": "project.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "projectID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Update project", + "description": "Update project properties such as name, icon, and commands.", + "responses": { + "200": { + "description": "Updated project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + } + }, + "commands": { + "type": "object", + "properties": { + "start": { + "description": "Startup script to run when creating a new workspace (worktree)", + "type": "string" + } + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + } + ] + } + }, + "/pty": { + "get": { + "operationId": "pty.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List PTY sessions", + "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + "responses": { + "200": { + "description": "List of sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + } + ] + }, + "post": { + "operationId": "pty.create", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Create PTY session", + "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + "responses": { + "200": { + "description": "Created session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}": { + "get": { + "operationId": "pty.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Get PTY session", + "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + "responses": { + "200": { + "description": "Session info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + } + ] + }, + "put": { + "operationId": "pty.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Update PTY session", + "description": "Update properties of an existing pseudo-terminal (PTY) session.", + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "number" + }, + "cols": { + "type": "number" + } + }, + "required": ["rows", "cols"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "pty.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Remove PTY session", + "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", + "responses": { + "200": { + "description": "Session removed", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}/connect": { + "get": { + "operationId": "pty.connect", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Connect to PTY session", + "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + "responses": { + "200": { + "description": "Connected session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + } + ] + } + }, + "/config": { + "get": { + "operationId": "config.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get configuration", + "description": "Retrieve the current OpenCode configuration settings and preferences.", + "responses": { + "200": { + "description": "Get config info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" + } + ] + }, + "patch": { + "operationId": "config.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Update configuration", + "description": "Update OpenCode configuration settings and preferences.", + "responses": { + "200": { + "description": "Successfully updated config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" + } + ] + } + }, + "/config/providers": { + "get": { + "operationId": "config.providers", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List config providers", + "description": "Get a list of all configured AI providers and their default models.", + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["providers", "default"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" + } + ] + } + }, + "/experimental/tool/ids": { + "get": { + "operationId": "tool.ids", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List tool IDs", + "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + "responses": { + "200": { + "description": "Tool IDs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolIDs" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + } + ] + } + }, + "/experimental/tool": { + "get": { + "operationId": "tool.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "provider", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "model", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "List tools", + "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + "responses": { + "200": { + "description": "Tools", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolList" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" + } + ] + } + }, + "/experimental/worktree": { + "post": { + "operationId": "worktree.create", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Create worktree", + "description": "Create a new git worktree for the current project and run any configured startup scripts.", + "responses": { + "200": { + "description": "Worktree created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Worktree" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorktreeCreateInput" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.create({\n ...\n})" + } + ] + }, + "get": { + "operationId": "worktree.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List worktrees", + "description": "List all sandbox worktrees for the current project.", + "responses": { + "200": { + "description": "List of worktree directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "worktree.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Remove worktree", + "description": "Remove a git worktree and delete its branch.", + "responses": { + "200": { + "description": "Worktree removed", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorktreeRemoveInput" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.remove({\n ...\n})" + } + ] + } + }, + "/experimental/worktree/reset": { + "post": { + "operationId": "worktree.reset", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Reset worktree", + "description": "Reset a worktree branch to the primary default branch.", + "responses": { + "200": { + "description": "Worktree reset", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorktreeResetInput" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.reset({\n ...\n})" + } + ] + } + }, + "/experimental/resource": { + "get": { + "operationId": "experimental.resource.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get MCP resources", + "description": "Get all available MCP resources from connected servers. Optionally filter by name.", + "responses": { + "200": { + "description": "MCP resources", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/McpResource" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.resource.list({\n ...\n})" + } + ] + } + }, + "/session": { + "get": { + "operationId": "session.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + }, + "description": "Filter sessions by project directory" + }, + { + "in": "query", + "name": "roots", + "schema": { + "type": "boolean" + }, + "description": "Only return root sessions (no parentID)" + }, + { + "in": "query", + "name": "start", + "schema": { + "type": "number" + }, + "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + }, + "description": "Filter sessions by title (case-insensitive)" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "number" + }, + "description": "Maximum number of sessions to return" + } + ], + "summary": "List sessions", + "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", + "responses": { + "200": { + "description": "List of sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.list({\n ...\n})" + } + ] + }, + "post": { + "operationId": "session.create", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Create session", + "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + "responses": { + "200": { + "description": "Successfully created session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "parentID": { + "type": "string", + "pattern": "^ses.*" + }, + "title": { + "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.create({\n ...\n})" + } + ] + } + }, + "/session/status": { + "get": { + "operationId": "session.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get session status", + "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", + "responses": { + "200": { + "description": "Get session status", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/SessionStatus" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.status({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}": { + "get": { + "operationId": "session.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Get session", + "description": "Retrieve detailed information about a specific OpenCode session.", + "tags": ["Session"], + "responses": { + "200": { + "description": "Get session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.get({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "session.delete", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Delete session", + "description": "Delete a session and permanently remove all associated data, including messages and history.", + "responses": { + "200": { + "description": "Successfully deleted session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.delete({\n ...\n})" + } + ] + }, + "patch": { + "operationId": "session.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Update session", + "description": "Update properties of an existing session, such as title or other metadata.", + "responses": { + "200": { + "description": "Successfully updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "archived": { + "type": "number" + } + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.update({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/children": { + "get": { + "operationId": "session.children", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Get session children", + "tags": ["Session"], + "description": "Retrieve all child sessions that were forked from the specified parent session.", + "responses": { + "200": { + "description": "List of children", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.children({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/todo": { + "get": { + "operationId": "session.todo", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Get session todos", + "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", + "responses": { + "200": { + "description": "Todo list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.todo({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/init": { + "post": { + "operationId": "session.init", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Initialize session", + "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": ["modelID", "providerID", "messageID"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/fork": { + "post": { + "operationId": "session.fork", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Fork session", + "description": "Create a new session by forking an existing session at a specific message point.", + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.fork({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/abort": { + "post": { + "operationId": "session.abort", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Abort session", + "description": "Abort an active session and stop any ongoing AI processing or command execution.", + "responses": { + "200": { + "description": "Aborted session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.abort({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/share": { + "post": { + "operationId": "session.share", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Share session", + "description": "Create a shareable link for a session, allowing others to view the conversation.", + "responses": { + "200": { + "description": "Successfully shared session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.share({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "session.unshare", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Unshare session", + "description": "Remove the shareable link for a session, making it private again.", + "responses": { + "200": { + "description": "Successfully unshared session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unshare({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/diff": { + "get": { + "operationId": "session.diff", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "in": "query", + "name": "messageID", + "schema": { + "type": "string", + "pattern": "^msg.*" + } + } + ], + "summary": "Get message diff", + "description": "Get the file changes (diff) that resulted from a specific user message in the session.", + "responses": { + "200": { + "description": "Successfully retrieved diff", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/summarize": { + "post": { + "operationId": "session.summarize", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Summarize session", + "description": "Generate a concise summary of the session using AI compaction to preserve key information.", + "responses": { + "200": { + "description": "Summarized session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "auto": { + "default": false, + "type": "boolean" + } + }, + "required": ["providerID", "modelID"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.summarize({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message": { + "get": { + "operationId": "session.messages", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "number" + } + } + ], + "summary": "Get session messages", + "description": "Retrieve all messages in a session, including user prompts and AI responses.", + "responses": { + "200": { + "description": "List of messages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"] + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" + } + ] + }, + "post": { + "operationId": "session.prompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Send message", + "description": "Create and send a new message to a session, streaming the AI response.", + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "system": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": ["parts"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message/{messageID}": { + "get": { + "operationId": "session.message", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "path", + "name": "messageID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Message ID" + } + ], + "summary": "Get message", + "description": "Retrieve a specific message from a session by its message ID.", + "responses": { + "200": { + "description": "Message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message/{messageID}/part/{partID}": { + "delete": { + "operationId": "part.delete", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "path", + "name": "messageID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Message ID" + }, + { + "in": "path", + "name": "partID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Part ID" + } + ], + "description": "Delete a part from a message", + "responses": { + "200": { + "description": "Successfully deleted part", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.delete({\n ...\n})" + } + ] + }, + "patch": { + "operationId": "part.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "path", + "name": "messageID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Message ID" + }, + { + "in": "path", + "name": "partID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Part ID" + } + ], + "description": "Update a part in a message", + "responses": { + "200": { + "description": "Successfully updated part", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Part" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Part" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.update({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/prompt_async": { + "post": { + "operationId": "session.prompt_async", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Send async message", + "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + "responses": { + "204": { + "description": "Prompt accepted" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "system": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": ["parts"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/command": { + "post": { + "operationId": "session.command", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Send command", + "description": "Send a new command to a session for execution by the AI assistant.", + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "command": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"] + } + ] + } + } + }, + "required": ["arguments", "command"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/shell": { + "post": { + "operationId": "session.shell", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Run shell command", + "description": "Execute a shell command within the session context and return the AI's response.", + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantMessage" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] + }, + "command": { + "type": "string" + } + }, + "required": ["agent", "command"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/revert": { + "post": { + "operationId": "session.revert", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Revert message", + "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "partID": { + "type": "string", + "pattern": "^prt.*" + } + }, + "required": ["messageID"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/unrevert": { + "post": { + "operationId": "session.unrevert", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Restore reverted messages", + "description": "Restore all previously reverted messages in a session.", + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/permissions/{permissionID}": { + "post": { + "operationId": "permission.respond", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "permissionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Respond to permission", + "deprecated": true, + "description": "Approve or deny a permission request from the AI assistant.", + "responses": { + "200": { + "description": "Permission processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["response"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" + } + ] + } + }, + "/permission/{requestID}/reply": { + "post": { + "operationId": "permission.reply", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Respond to permission request", + "description": "Approve or deny a permission request from the AI assistant.", + "responses": { + "200": { + "description": "Permission processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + }, + "message": { + "type": "string" + } + }, + "required": ["reply"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" + } + ] + } + }, + "/permission": { + "get": { + "operationId": "permission.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List pending permissions", + "description": "Get all pending permission requests across all sessions.", + "responses": { + "200": { + "description": "List of pending permissions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRequest" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + } + ] + } + }, + "/question": { + "get": { + "operationId": "question.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List pending questions", + "description": "Get all pending question requests across all sessions.", + "responses": { + "200": { + "description": "List of pending questions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionRequest" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reply": { + "post": { + "operationId": "question.reply", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Reply to question request", + "description": "Provide answers to a question request from the AI assistant.", + "responses": { + "200": { + "description": "Question answered successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answers": { + "description": "User answers in order of questions (each answer is an array of selected labels)", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["answers"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reject": { + "post": { + "operationId": "question.reject", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Reject question request", + "description": "Reject a question request from the AI assistant.", + "responses": { + "200": { + "description": "Question rejected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" + } + ] + } + }, + "/provider": { + "get": { + "operationId": "provider.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List providers", + "description": "Get a list of all available AI providers, including both available and connected ones.", + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { + "type": "object", + "properties": { + "api": { + "type": "string" + }, + "name": { + "type": "string" + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "npm": { + "type": "string" + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "family": { + "type": "string" + }, + "release_date": { + "type": "string" + }, + "attachment": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "temperature": { + "type": "boolean" + }, + "tool_call": { + "type": "boolean" + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": ["reasoning_content", "reasoning_details"] + } + }, + "required": ["field"], + "additionalProperties": false + } + ] + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + }, + "context_over_200k": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + } + }, + "required": ["input", "output"] + } + }, + "required": ["input", "output"] + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": ["context", "output"] + }, + "modalities": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + }, + "output": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + } + }, + "required": ["input", "output"] + }, + "experimental": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated"] + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "provider": { + "type": "object", + "properties": { + "npm": { + "type": "string" + } + }, + "required": ["npm"] + }, + "variants": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + } + }, + "required": [ + "id", + "name", + "release_date", + "attachment", + "reasoning", + "temperature", + "tool_call", + "limit", + "options" + ] + } + } + }, + "required": ["name", "env", "id", "models"] + } + }, + "default": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "connected": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["all", "default", "connected"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + } + ] + } + }, + "/provider/auth": { + "get": { + "operationId": "provider.auth", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get provider auth methods", + "description": "Retrieve available authentication methods for all AI providers.", + "responses": { + "200": { + "description": "Provider auth methods", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethod" + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/authorize": { + "post": { + "operationId": "provider.oauth.authorize", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Provider ID" + } + ], + "summary": "OAuth authorize", + "description": "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", + "responses": { + "200": { + "description": "Authorization URL and method", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderAuthAuthorization" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "description": "Auth method index", + "type": "number" + } + }, + "required": ["method"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/callback": { + "post": { + "operationId": "provider.oauth.callback", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Provider ID" + } + ], + "summary": "OAuth callback", + "description": "Handle the OAuth callback from a provider after user authorization.", + "responses": { + "200": { + "description": "OAuth callback processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "description": "Auth method index", + "type": "number" + }, + "code": { + "description": "OAuth authorization code", + "type": "string" + } + }, + "required": ["method"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" + } + ] + } + }, + "/find": { + "get": { + "operationId": "find.text", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "pattern", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Find text", + "description": "Search for text patterns across files in the project using ripgrep.", + "responses": { + "200": { + "description": "Matches", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] + }, + "lines": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] + }, + "line_number": { + "type": "number" + }, + "absolute_offset": { + "type": "number" + }, + "submatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": ["match", "start", "end"] + } + } + }, + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" + } + ] + } + }, + "/find/file": { + "get": { + "operationId": "find.files", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "dirs", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "in": "query", + "name": "type", + "schema": { + "type": "string", + "enum": ["file", "directory"] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } + } + ], + "summary": "Find files", + "description": "Search for files or directories by name or pattern in the project directory.", + "responses": { + "200": { + "description": "File paths", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" + } + ] + } + }, + "/find/symbol": { + "get": { + "operationId": "find.symbols", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Find symbols", + "description": "Search for workspace symbols like functions, classes, and variables using LSP.", + "responses": { + "200": { + "description": "Symbols", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" + } + ] + } + }, + "/file": { + "get": { + "operationId": "file.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "List files", + "description": "List files and directories in a specified path.", + "responses": { + "200": { + "description": "Files and directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileNode" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" + } + ] + } + }, + "/file/content": { + "get": { + "operationId": "file.read", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Read file", + "description": "Read the content of a specified file.", + "responses": { + "200": { + "description": "File content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileContent" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" + } + ] + } + }, + "/file/status": { + "get": { + "operationId": "file.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get file status", + "description": "Get the git status of all files in the project.", + "responses": { + "200": { + "description": "File status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" + } + ] + } + }, + "/mcp": { + "get": { + "operationId": "mcp.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get MCP status", + "description": "Get the status of all Model Context Protocol (MCP) servers.", + "responses": { + "200": { + "description": "MCP server status", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" + } + ] + }, + "post": { + "operationId": "mcp.add", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Add MCP server", + "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", + "responses": { + "200": { + "description": "MCP server added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + } + }, + "required": ["name", "config"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth": { + "post": { + "operationId": "mcp.auth.start", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Start MCP OAuth", + "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + "responses": { + "200": { + "description": "OAuth flow started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorizationUrl": { + "description": "URL to open in browser for authorization", + "type": "string" + } + }, + "required": ["authorizationUrl"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "mcp.auth.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Remove MCP OAuth", + "description": "Remove OAuth credentials for an MCP server", + "responses": { + "200": { + "description": "OAuth credentials removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + } + }, + "required": ["success"] + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/callback": { + "post": { + "operationId": "mcp.auth.callback", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Complete MCP OAuth", + "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "description": "Authorization code from OAuth callback", + "type": "string" + } + }, + "required": ["code"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/authenticate": { + "post": { + "operationId": "mcp.auth.authenticate", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Authenticate MCP OAuth", + "description": "Start OAuth flow and wait for callback (opens browser)", + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/connect": { + "post": { + "operationId": "mcp.connect", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "name", + "schema": { + "type": "string" + }, + "required": true + } + ], + "description": "Connect an MCP server", + "responses": { + "200": { + "description": "MCP server connected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/disconnect": { + "post": { + "operationId": "mcp.disconnect", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "name", + "schema": { + "type": "string" + }, + "required": true + } + ], + "description": "Disconnect an MCP server", + "responses": { + "200": { + "description": "MCP server disconnected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + } + ] + } + }, + "/tui/append-prompt": { + "post": { + "operationId": "tui.appendPrompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Append TUI prompt", + "description": "Append prompt to the TUI", + "responses": { + "200": { + "description": "Prompt processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.appendPrompt({\n ...\n})" + } + ] + } + }, + "/tui/open-help": { + "post": { + "operationId": "tui.openHelp", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open help dialog", + "description": "Open the help dialog in the TUI to display user assistance information.", + "responses": { + "200": { + "description": "Help dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openHelp({\n ...\n})" + } + ] + } + }, + "/tui/open-sessions": { + "post": { + "operationId": "tui.openSessions", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open sessions dialog", + "description": "Open the session dialog", + "responses": { + "200": { + "description": "Session dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openSessions({\n ...\n})" + } + ] + } + }, + "/tui/open-themes": { + "post": { + "operationId": "tui.openThemes", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open themes dialog", + "description": "Open the theme dialog", + "responses": { + "200": { + "description": "Theme dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openThemes({\n ...\n})" + } + ] + } + }, + "/tui/open-models": { + "post": { + "operationId": "tui.openModels", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open models dialog", + "description": "Open the model dialog", + "responses": { + "200": { + "description": "Model dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openModels({\n ...\n})" + } + ] + } + }, + "/tui/submit-prompt": { + "post": { + "operationId": "tui.submitPrompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Submit TUI prompt", + "description": "Submit the prompt", + "responses": { + "200": { + "description": "Prompt submitted successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.submitPrompt({\n ...\n})" + } + ] + } + }, + "/tui/clear-prompt": { + "post": { + "operationId": "tui.clearPrompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Clear TUI prompt", + "description": "Clear the prompt", + "responses": { + "200": { + "description": "Prompt cleared successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.clearPrompt({\n ...\n})" + } + ] + } + }, + "/tui/execute-command": { + "post": { + "operationId": "tui.executeCommand", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Execute TUI command", + "description": "Execute a TUI command (e.g. agent_cycle)", + "responses": { + "200": { + "description": "Command executed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + } + }, + "required": ["command"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.executeCommand({\n ...\n})" + } + ] + } + }, + "/tui/show-toast": { + "post": { + "operationId": "tui.showToast", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Show TUI toast", + "description": "Show a toast notification in the TUI", + "responses": { + "200": { + "description": "Toast notification shown successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "description": "Duration in milliseconds", + "default": 5000, + "type": "number" + } + }, + "required": ["message", "variant"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.showToast({\n ...\n})" + } + ] + } + }, + "/tui/publish": { + "post": { + "operationId": "tui.publish", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Publish TUI event", + "description": "Publish a TUI event", + "responses": { + "200": { + "description": "Event published successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + } + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.publish({\n ...\n})" + } + ] + } + }, + "/tui/select-session": { + "post": { + "operationId": "tui.selectSession", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Select session", + "description": "Navigate the TUI to display the specified session.", + "responses": { + "200": { + "description": "Session selected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "description": "Session ID to navigate to", + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.selectSession({\n ...\n})" + } + ] + } + }, + "/tui/control/next": { + "get": { + "operationId": "tui.control.next", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get next TUI request", + "description": "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", + "responses": { + "200": { + "description": "Next TUI request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "body": {} + }, + "required": ["path", "body"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.control.next({\n ...\n})" + } + ] + } + }, + "/tui/control/response": { + "post": { + "operationId": "tui.control.response", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Submit TUI response", + "description": "Submit a response to the TUI request queue to complete a pending request.", + "responses": { + "200": { + "description": "Response submitted successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.control.response({\n ...\n})" + } + ] + } + }, + "/instance/dispose": { + "post": { + "operationId": "instance.dispose", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Dispose instance", + "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", + "responses": { + "200": { + "description": "Instance disposed", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" + } + ] + } + }, + "/path": { + "get": { + "operationId": "path.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get paths", + "description": "Retrieve the current working directory and related path information for the OpenCode instance.", + "responses": { + "200": { + "description": "Path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Path" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" + } + ] + } + }, + "/vcs": { + "get": { + "operationId": "vcs.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get VCS info", + "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", + "responses": { + "200": { + "description": "VCS info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsInfo" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" + } + ] + } + }, + "/command": { + "get": { + "operationId": "command.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List commands", + "description": "Get a list of all available commands in the OpenCode system.", + "responses": { + "200": { + "description": "List of commands", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" + } + ] + } + }, + "/log": { + "post": { + "operationId": "app.log", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Write log", + "description": "Write a log entry to the server logs with specified level and metadata.", + "responses": { + "200": { + "description": "Log entry written successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "description": "Service name for the log entry", + "type": "string" + }, + "level": { + "description": "Log level", + "type": "string", + "enum": ["debug", "info", "error", "warn"] + }, + "message": { + "description": "Log message", + "type": "string" + }, + "extra": { + "description": "Additional metadata for the log entry", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["service", "level", "message"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + } + ] + } + }, + "/agent": { + "get": { + "operationId": "app.agents", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List agents", + "description": "Get a list of all available AI agents in the OpenCode system.", + "responses": { + "200": { + "description": "List of agents", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + } + ] + } + }, + "/skill": { + "get": { + "operationId": "app.skills", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List skills", + "description": "Get a list of all available skills in the OpenCode system.", + "responses": { + "200": { + "description": "List of skills", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": ["name", "description", "location", "content"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + } + ] + } + }, + "/lsp": { + "get": { + "operationId": "lsp.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get LSP status", + "description": "Get LSP server status", + "responses": { + "200": { + "description": "LSP server status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LSPStatus" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + } + ] + } + }, + "/formatter": { + "get": { + "operationId": "formatter.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get formatter status", + "description": "Get formatter status", + "responses": { + "200": { + "description": "Formatter status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FormatterStatus" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + } + ] + } + }, + "/event": { + "get": { + "operationId": "event.subscribe", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Subscribe to events", + "description": "Get events", + "responses": { + "200": { + "description": "Event stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" + } + ] + } + } + }, + "components": { + "schemas": { + "Event.installation.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "installation.updated" + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"] + } + }, + "required": ["type", "properties"] + }, + "Event.installation.update-available": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "installation.update-available" + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"] + } + }, + "required": ["type", "properties"] + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "const": "git" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + } + }, + "commands": { + "type": "object", + "properties": { + "start": { + "description": "Startup script to run when creating a new workspace (worktree)", + "type": "string" + } + } + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "initialized": { + "type": "number" + } + }, + "required": ["created", "updated"] + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"] + }, + "Event.project.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "project.updated" + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["type", "properties"] + }, + "Event.server.instance.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.instance.disposed" + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"] + } + }, + "required": ["type", "properties"] + }, + "Event.server.connected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.connected" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, + "Event.global.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "global.disposed" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, + "Event.lsp.client.diagnostics": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "lsp.client.diagnostics" + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"] + } + }, + "required": ["type", "properties"] + }, + "Event.lsp.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "lsp.updated" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "FileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "before": { + "type": "string" + }, + "after": { + "type": "string" + }, + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] + } + }, + "required": ["file", "before", "after", "additions", "deletions"] + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "role": { + "type": "string", + "const": "user" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"] + }, + "summary": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["diffs"] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] + }, + "system": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "sessionID", "role", "time", "agent", "model"] + }, + "ProviderAuthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "ProviderAuthError" + }, + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["providerID", "message"] + } + }, + "required": ["name", "data"] + }, + "UnknownError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "UnknownError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["name", "data"] + }, + "MessageOutputLengthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "MessageOutputLengthError" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["name", "data"] + }, + "MessageAbortedError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "MessageAbortedError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["name", "data"] + }, + "APIError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "APIError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"] + } + }, + "required": ["name", "data"] + }, + "AssistantMessage": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "role": { + "type": "string", + "const": "assistant" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + "parentID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": ["cwd", "root"] + }, + "summary": { + "type": "boolean" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + }, + "finish": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ] + }, + "Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + } + ] + }, + "Event.message.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.updated" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.message.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.removed" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"] + } + }, + "required": ["type", "properties"] + }, + "TextPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": ["start"] + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["id", "sessionID", "messageID", "type", "text"] + }, + "SubtaskPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "subtask" + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] + }, + "command": { + "type": "string" + } + }, + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] + }, + "ReasoningPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "reasoning" + }, + "text": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": ["start"] + } + }, + "required": ["id", "sessionID", "messageID", "type", "text", "time"] + }, + "FilePartSourceText": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["value", "start", "end"] + }, + "FileSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "const": "file" + }, + "path": { + "type": "string" + } + }, + "required": ["text", "type", "path"] + }, + "Range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "number" + }, + "character": { + "type": "number" + } + }, + "required": ["line", "character"] + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "number" + }, + "character": { + "type": "number" + } + }, + "required": ["line", "character"] + } + }, + "required": ["start", "end"] + }, + "SymbolSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "const": "symbol" + }, + "path": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["text", "type", "path", "range", "name", "kind"] + }, + "ResourceSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "const": "resource" + }, + "clientName": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": ["text", "type", "clientName", "uri"] + }, + "FilePartSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSource" + }, + { + "$ref": "#/components/schemas/SymbolSource" + }, + { + "$ref": "#/components/schemas/ResourceSource" + } + ] + }, + "FilePart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["id", "sessionID", "messageID", "type", "mime", "url"] + }, + "ToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "pending" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "raw": { + "type": "string" + } + }, + "required": ["status", "input", "raw"] + }, + "ToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "running" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + } + }, + "required": ["start"] + } + }, + "required": ["status", "input", "time"] + }, + "ToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "completed" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "output": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "compacted": { + "type": "number" + } + }, + "required": ["start", "end"] + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePart" + } + } + }, + "required": ["status", "input", "output", "title", "metadata", "time"] + }, + "ToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "error" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "error": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": ["start", "end"] + } + }, + "required": ["status", "input", "error", "time"] + }, + "ToolState": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolStatePending" + }, + { + "$ref": "#/components/schemas/ToolStateRunning" + }, + { + "$ref": "#/components/schemas/ToolStateCompleted" + }, + { + "$ref": "#/components/schemas/ToolStateError" + } + ] + }, + "ToolPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "tool" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ToolState" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] + }, + "StepStartPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "step-start" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["id", "sessionID", "messageID", "type"] + }, + "StepFinishPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "step-finish" + }, + "reason": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + } + }, + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] + }, + "SnapshotPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "snapshot" + }, + "snapshot": { + "type": "string" + } + }, + "required": ["id", "sessionID", "messageID", "type", "snapshot"] + }, + "PatchPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "patch" + }, + "hash": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "sessionID", "messageID", "type", "hash", "files"] + }, + "AgentPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "agent" + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["value", "start", "end"] + } + }, + "required": ["id", "sessionID", "messageID", "type", "name"] + }, + "RetryPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/APIError" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"] + } + }, + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] + }, + "CompactionPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "compaction" + }, + "auto": { + "type": "boolean" + } + }, + "required": ["id", "sessionID", "messageID", "type", "auto"] + }, + "Part": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPart" + }, + { + "$ref": "#/components/schemas/SubtaskPart" + }, + { + "$ref": "#/components/schemas/ReasoningPart" + }, + { + "$ref": "#/components/schemas/FilePart" + }, + { + "$ref": "#/components/schemas/ToolPart" + }, + { + "$ref": "#/components/schemas/StepStartPart" + }, + { + "$ref": "#/components/schemas/StepFinishPart" + }, + { + "$ref": "#/components/schemas/SnapshotPart" + }, + { + "$ref": "#/components/schemas/PatchPart" + }, + { + "$ref": "#/components/schemas/AgentPart" + }, + { + "$ref": "#/components/schemas/RetryPart" + }, + { + "$ref": "#/components/schemas/CompactionPart" + } + ] + }, + "Event.message.part.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.part.updated" + }, + "properties": { + "type": "object", + "properties": { + "part": { + "$ref": "#/components/schemas/Part" + }, + "delta": { + "type": "string" + } + }, + "required": ["part"] + } + }, + "required": ["type", "properties"] + }, + "Event.message.part.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.part.removed" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"] + } + }, + "required": ["type", "properties"] + }, + "PermissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] + }, + "Event.permission.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.asked" + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["type", "properties"] + }, + "Event.permission.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"] + } + }, + "required": ["type", "properties"] + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "message": { + "type": "string" + }, + "next": { + "type": "number" + } + }, + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] + } + ] + }, + "Event.session.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.status" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.idle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.idle" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" + }, + "description": { + "description": "Explanation of choice", + "type": "string" + } + }, + "required": ["label", "description"] + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "description": "Complete question", + "type": "string" + }, + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" + } + }, + "required": ["question", "header", "options"] + }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["type", "properties"] + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"] + } + }, + "required": ["type", "properties"] + }, + "Event.question.rejected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.rejected" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + } + }, + "required": ["sessionID", "requestID"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.compacted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.compacted" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + }, + "id": { + "description": "Unique identifier for the todo item", + "type": "string" + } + }, + "required": ["content", "status", "priority", "id"] + }, + "Event.todo.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "todo.updated" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.prompt.append" + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.command.execute" + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.toast.show" + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "description": "Duration in milliseconds", + "default": 5000, + "type": "number" + } + }, + "required": ["message", "variant"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.session.select" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "description": "Session ID to navigate to", + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.tools.changed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.tools.changed" + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.browser.open.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.browser.open.failed" + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"] + } + }, + "required": ["type", "properties"] + }, + "Event.command.executed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command.executed" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"] + } + }, + "required": ["type", "properties"] + }, + "PermissionAction": { + "type": "string", + "enum": ["allow", "deny", "ask"] + }, + "PermissionRule": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "action": { + "$ref": "#/components/schemas/PermissionAction" + } + }, + "required": ["permission", "pattern", "action"] + }, + "PermissionRuleset": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRule" + } + }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses.*" + }, + "slug": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses.*" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["additions", "deletions", "files"] + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"] + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "compacting": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"] + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"] + } + }, + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] + }, + "Event.session.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.created" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.updated" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.deleted" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.diff": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.diff" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": ["sessionID", "diff"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.error" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + } + } + }, + "required": ["type", "properties"] + }, + "Event.vcs.branch.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "vcs.branch.updated" + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + } + } + }, + "required": ["type", "properties"] + }, + "Pty": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + }, + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["running", "exited"] + }, + "pid": { + "type": "number" + } + }, + "required": ["id", "title", "command", "args", "cwd", "status", "pid"] + }, + "Event.pty.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.created" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.pty.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.updated" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"] + } + }, + "required": ["type", "properties"] + }, + "Event.pty.exited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.exited" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + }, + "exitCode": { + "type": "number" + } + }, + "required": ["id", "exitCode"] + } + }, + "required": ["type", "properties"] + }, + "Event.pty.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.deleted" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + } + }, + "required": ["id"] + } + }, + "required": ["type", "properties"] + }, + "Event.worktree.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "worktree.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name", "branch"] + } + }, + "required": ["type", "properties"] + }, + "Event.worktree.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "worktree.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/Event.installation.updated" + }, + { + "$ref": "#/components/schemas/Event.installation.update-available" + }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + }, + { + "$ref": "#/components/schemas/Event.lsp.client.diagnostics" + }, + { + "$ref": "#/components/schemas/Event.lsp.updated" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.message.updated" + }, + { + "$ref": "#/components/schemas/Event.message.removed" + }, + { + "$ref": "#/components/schemas/Event.message.part.updated" + }, + { + "$ref": "#/components/schemas/Event.message.part.removed" + }, + { + "$ref": "#/components/schemas/Event.permission.asked" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, + { + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.question.asked" + }, + { + "$ref": "#/components/schemas/Event.question.replied" + }, + { + "$ref": "#/components/schemas/Event.question.rejected" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/Event.mcp.tools.changed" + }, + { + "$ref": "#/components/schemas/Event.mcp.browser.open.failed" + }, + { + "$ref": "#/components/schemas/Event.command.executed" + }, + { + "$ref": "#/components/schemas/Event.session.created" + }, + { + "$ref": "#/components/schemas/Event.session.updated" + }, + { + "$ref": "#/components/schemas/Event.session.deleted" + }, + { + "$ref": "#/components/schemas/Event.session.diff" + }, + { + "$ref": "#/components/schemas/Event.session.error" + }, + { + "$ref": "#/components/schemas/Event.vcs.branch.updated" + }, + { + "$ref": "#/components/schemas/Event.pty.created" + }, + { + "$ref": "#/components/schemas/Event.pty.updated" + }, + { + "$ref": "#/components/schemas/Event.pty.exited" + }, + { + "$ref": "#/components/schemas/Event.pty.deleted" + }, + { + "$ref": "#/components/schemas/Event.worktree.ready" + }, + { + "$ref": "#/components/schemas/Event.worktree.failed" + } + ] + }, + "GlobalEvent": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/Event" + } + }, + "required": ["directory", "payload"] + }, + "KeybindsConfig": { + "description": "Custom keybind configurations", + "type": "object", + "properties": { + "leader": { + "description": "Leader key for keybind combinations", + "default": "ctrl+x", + "type": "string" + }, + "app_exit": { + "description": "Exit the application", + "default": "ctrl+c,ctrl+d,<leader>q", + "type": "string" + }, + "editor_open": { + "description": "Open external editor", + "default": "<leader>e", + "type": "string" + }, + "theme_list": { + "description": "List available themes", + "default": "<leader>t", + "type": "string" + }, + "sidebar_toggle": { + "description": "Toggle sidebar", + "default": "<leader>b", + "type": "string" + }, + "scrollbar_toggle": { + "description": "Toggle session scrollbar", + "default": "none", + "type": "string" + }, + "username_toggle": { + "description": "Toggle username visibility", + "default": "none", + "type": "string" + }, + "status_view": { + "description": "View status", + "default": "<leader>s", + "type": "string" + }, + "session_export": { + "description": "Export session to editor", + "default": "<leader>x", + "type": "string" + }, + "session_new": { + "description": "Create a new session", + "default": "<leader>n", + "type": "string" + }, + "session_list": { + "description": "List all sessions", + "default": "<leader>l", + "type": "string" + }, + "session_timeline": { + "description": "Show session timeline", + "default": "<leader>g", + "type": "string" + }, + "session_fork": { + "description": "Fork session from message", + "default": "none", + "type": "string" + }, + "session_rename": { + "description": "Rename session", + "default": "ctrl+r", + "type": "string" + }, + "session_delete": { + "description": "Delete session", + "default": "ctrl+d", + "type": "string" + }, + "stash_delete": { + "description": "Delete stash entry", + "default": "ctrl+d", + "type": "string" + }, + "model_provider_list": { + "description": "Open provider list from model dialog", + "default": "ctrl+a", + "type": "string" + }, + "model_favorite_toggle": { + "description": "Toggle model favorite status", + "default": "ctrl+f", + "type": "string" + }, + "session_share": { + "description": "Share current session", + "default": "none", + "type": "string" + }, + "session_unshare": { + "description": "Unshare current session", + "default": "none", + "type": "string" + }, + "session_interrupt": { + "description": "Interrupt current session", + "default": "escape", + "type": "string" + }, + "session_compact": { + "description": "Compact the session", + "default": "<leader>c", + "type": "string" + }, + "messages_page_up": { + "description": "Scroll messages up by one page", + "default": "pageup,ctrl+alt+b", + "type": "string" + }, + "messages_page_down": { + "description": "Scroll messages down by one page", + "default": "pagedown,ctrl+alt+f", + "type": "string" + }, + "messages_line_up": { + "description": "Scroll messages up by one line", + "default": "ctrl+alt+y", + "type": "string" + }, + "messages_line_down": { + "description": "Scroll messages down by one line", + "default": "ctrl+alt+e", + "type": "string" + }, + "messages_half_page_up": { + "description": "Scroll messages up by half page", + "default": "ctrl+alt+u", + "type": "string" + }, + "messages_half_page_down": { + "description": "Scroll messages down by half page", + "default": "ctrl+alt+d", + "type": "string" + }, + "messages_first": { + "description": "Navigate to first message", + "default": "ctrl+g,home", + "type": "string" + }, + "messages_last": { + "description": "Navigate to last message", + "default": "ctrl+alt+g,end", + "type": "string" + }, + "messages_next": { + "description": "Navigate to next message", + "default": "none", + "type": "string" + }, + "messages_previous": { + "description": "Navigate to previous message", + "default": "none", + "type": "string" + }, + "messages_last_user": { + "description": "Navigate to last user message", + "default": "none", + "type": "string" + }, + "messages_copy": { + "description": "Copy message", + "default": "<leader>y", + "type": "string" + }, + "messages_undo": { + "description": "Undo message", + "default": "<leader>u", + "type": "string" + }, + "messages_redo": { + "description": "Redo message", + "default": "<leader>r", + "type": "string" + }, + "messages_toggle_conceal": { + "description": "Toggle code block concealment in messages", + "default": "<leader>h", + "type": "string" + }, + "tool_details": { + "description": "Toggle tool details visibility", + "default": "none", + "type": "string" + }, + "model_list": { + "description": "List available models", + "default": "<leader>m", + "type": "string" + }, + "model_cycle_recent": { + "description": "Next recently used model", + "default": "f2", + "type": "string" + }, + "model_cycle_recent_reverse": { + "description": "Previous recently used model", + "default": "shift+f2", + "type": "string" + }, + "model_cycle_favorite": { + "description": "Next favorite model", + "default": "none", + "type": "string" + }, + "model_cycle_favorite_reverse": { + "description": "Previous favorite model", + "default": "none", + "type": "string" + }, + "command_list": { + "description": "List available commands", + "default": "ctrl+p", + "type": "string" + }, + "agent_list": { + "description": "List agents", + "default": "<leader>a", + "type": "string" + }, + "agent_cycle": { + "description": "Next agent", + "default": "tab", + "type": "string" + }, + "agent_cycle_reverse": { + "description": "Previous agent", + "default": "shift+tab", + "type": "string" + }, + "variant_cycle": { + "description": "Cycle model variants", + "default": "ctrl+t", + "type": "string" + }, + "input_clear": { + "description": "Clear input field", + "default": "ctrl+c", + "type": "string" + }, + "input_paste": { + "description": "Paste from clipboard", + "default": "ctrl+v", + "type": "string" + }, + "input_submit": { + "description": "Submit input", + "default": "return", + "type": "string" + }, + "input_newline": { + "description": "Insert newline in input", + "default": "shift+return,ctrl+return,alt+return,ctrl+j", + "type": "string" + }, + "input_move_left": { + "description": "Move cursor left in input", + "default": "left,ctrl+b", + "type": "string" + }, + "input_move_right": { + "description": "Move cursor right in input", + "default": "right,ctrl+f", + "type": "string" + }, + "input_move_up": { + "description": "Move cursor up in input", + "default": "up", + "type": "string" + }, + "input_move_down": { + "description": "Move cursor down in input", + "default": "down", + "type": "string" + }, + "input_select_left": { + "description": "Select left in input", + "default": "shift+left", + "type": "string" + }, + "input_select_right": { + "description": "Select right in input", + "default": "shift+right", + "type": "string" + }, + "input_select_up": { + "description": "Select up in input", + "default": "shift+up", + "type": "string" + }, + "input_select_down": { + "description": "Select down in input", + "default": "shift+down", + "type": "string" + }, + "input_line_home": { + "description": "Move to start of line in input", + "default": "ctrl+a", + "type": "string" + }, + "input_line_end": { + "description": "Move to end of line in input", + "default": "ctrl+e", + "type": "string" + }, + "input_select_line_home": { + "description": "Select to start of line in input", + "default": "ctrl+shift+a", + "type": "string" + }, + "input_select_line_end": { + "description": "Select to end of line in input", + "default": "ctrl+shift+e", + "type": "string" + }, + "input_visual_line_home": { + "description": "Move to start of visual line in input", + "default": "alt+a", + "type": "string" + }, + "input_visual_line_end": { + "description": "Move to end of visual line in input", + "default": "alt+e", + "type": "string" + }, + "input_select_visual_line_home": { + "description": "Select to start of visual line in input", + "default": "alt+shift+a", + "type": "string" + }, + "input_select_visual_line_end": { + "description": "Select to end of visual line in input", + "default": "alt+shift+e", + "type": "string" + }, + "input_buffer_home": { + "description": "Move to start of buffer in input", + "default": "home", + "type": "string" + }, + "input_buffer_end": { + "description": "Move to end of buffer in input", + "default": "end", + "type": "string" + }, + "input_select_buffer_home": { + "description": "Select to start of buffer in input", + "default": "shift+home", + "type": "string" + }, + "input_select_buffer_end": { + "description": "Select to end of buffer in input", + "default": "shift+end", + "type": "string" + }, + "input_delete_line": { + "description": "Delete line in input", + "default": "ctrl+shift+d", + "type": "string" + }, + "input_delete_to_line_end": { + "description": "Delete to end of line in input", + "default": "ctrl+k", + "type": "string" + }, + "input_delete_to_line_start": { + "description": "Delete to start of line in input", + "default": "ctrl+u", + "type": "string" + }, + "input_backspace": { + "description": "Backspace in input", + "default": "backspace,shift+backspace", + "type": "string" + }, + "input_delete": { + "description": "Delete character in input", + "default": "ctrl+d,delete,shift+delete", + "type": "string" + }, + "input_undo": { + "description": "Undo in input", + "default": "ctrl+-,super+z", + "type": "string" + }, + "input_redo": { + "description": "Redo in input", + "default": "ctrl+.,super+shift+z", + "type": "string" + }, + "input_word_forward": { + "description": "Move word forward in input", + "default": "alt+f,alt+right,ctrl+right", + "type": "string" + }, + "input_word_backward": { + "description": "Move word backward in input", + "default": "alt+b,alt+left,ctrl+left", + "type": "string" + }, + "input_select_word_forward": { + "description": "Select word forward in input", + "default": "alt+shift+f,alt+shift+right", + "type": "string" + }, + "input_select_word_backward": { + "description": "Select word backward in input", + "default": "alt+shift+b,alt+shift+left", + "type": "string" + }, + "input_delete_word_forward": { + "description": "Delete word forward in input", + "default": "alt+d,alt+delete,ctrl+delete", + "type": "string" + }, + "input_delete_word_backward": { + "description": "Delete word backward in input", + "default": "ctrl+w,ctrl+backspace,alt+backspace", + "type": "string" + }, + "history_previous": { + "description": "Previous history item", + "default": "up", + "type": "string" + }, + "history_next": { + "description": "Next history item", + "default": "down", + "type": "string" + }, + "session_child_cycle": { + "description": "Next child session", + "default": "<leader>right", + "type": "string" + }, + "session_child_cycle_reverse": { + "description": "Previous child session", + "default": "<leader>left", + "type": "string" + }, + "session_parent": { + "description": "Go to parent session", + "default": "<leader>up", + "type": "string" + }, + "terminal_suspend": { + "description": "Suspend terminal", + "default": "ctrl+z", + "type": "string" + }, + "terminal_title_toggle": { + "description": "Toggle terminal title", + "default": "none", + "type": "string" + }, + "tips_toggle": { + "description": "Toggle tips on home screen", + "default": "<leader>h", + "type": "string" + } + }, + "additionalProperties": false + }, + "LogLevel": { + "description": "Log level", + "type": "string", + "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + }, + "ServerConfig": { + "description": "Server configuration for opencode serve and web commands", + "type": "object", + "properties": { + "port": { + "description": "Port to listen on", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "hostname": { + "description": "Hostname to listen on", + "type": "string" + }, + "mdns": { + "description": "Enable mDNS service discovery", + "type": "boolean" + }, + "mdnsDomain": { + "description": "Custom domain name for mDNS service (default: opencode.local)", + "type": "string" + }, + "cors": { + "description": "Additional domains to allow for CORS", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "PermissionActionConfig": { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + "PermissionObjectConfig": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/PermissionActionConfig" + } + }, + "PermissionRuleConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + { + "$ref": "#/components/schemas/PermissionObjectConfig" + } + ] + }, + "PermissionConfig": { + "anyOf": [ + { + "type": "object", + "properties": { + "__originalKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "read": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "edit": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "glob": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "grep": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "list": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "bash": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "task": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "external_directory": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "todowrite": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "todoread": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "question": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "webfetch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "websearch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "codesearch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "lsp": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "doom_loop": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "skill": { + "$ref": "#/components/schemas/PermissionRuleConfig" + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/PermissionRuleConfig" + } + }, + { + "$ref": "#/components/schemas/PermissionActionConfig" + } + ] + }, + "AgentConfig": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "description": "Default model variant for this agent (applies only when using the agent's configured model).", + "type": "string" + }, + "temperature": { + "type": "number" + }, + "top_p": { + "type": "number" + }, + "prompt": { + "type": "string" + }, + "tools": { + "description": "@deprecated Use 'permission' field instead", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "description": "Description of when to use the agent", + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["subagent", "primary", "all"] + }, + "hidden": { + "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", + "type": "boolean" + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "string", + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] + } + ] + }, + "steps": { + "description": "Maximum number of agentic iterations before forcing text-only response", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxSteps": { + "description": "@deprecated Use 'steps' field instead.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "permission": { + "$ref": "#/components/schemas/PermissionConfig" + } + }, + "additionalProperties": {} + }, + "ProviderConfig": { + "type": "object", + "properties": { + "api": { + "type": "string" + }, + "name": { + "type": "string" + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "npm": { + "type": "string" + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "family": { + "type": "string" + }, + "release_date": { + "type": "string" + }, + "attachment": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "temperature": { + "type": "boolean" + }, + "tool_call": { + "type": "boolean" + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": ["reasoning_content", "reasoning_details"] + } + }, + "required": ["field"], + "additionalProperties": false + } + ] + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + }, + "context_over_200k": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + } + }, + "required": ["input", "output"] + } + }, + "required": ["input", "output"] + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": ["context", "output"] + }, + "modalities": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + }, + "output": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + } + }, + "required": ["input", "output"] + }, + "experimental": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated"] + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "provider": { + "type": "object", + "properties": { + "npm": { + "type": "string" + } + }, + "required": ["npm"] + }, + "variants": { + "description": "Variant-specific configuration", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "disabled": { + "description": "Disable this variant for the model", + "type": "boolean" + } + }, + "additionalProperties": {} + } + } + } + } + }, + "whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "options": { + "type": "object", + "properties": { + "apiKey": { + "type": "string" + }, + "baseURL": { + "type": "string" + }, + "enterpriseUrl": { + "description": "GitHub Enterprise URL for copilot authentication", + "type": "string" + }, + "setCacheKey": { + "description": "Enable promptCacheKey for this provider (default false)", + "type": "boolean" + }, + "timeout": { + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "anyOf": [ + { + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "description": "Disable timeout for this provider entirely.", + "type": "boolean", + "const": false + } + ] + } + }, + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "McpLocalConfig": { + "type": "object", + "properties": { + "type": { + "description": "Type of MCP server connection", + "type": "string", + "const": "local" + }, + "command": { + "description": "Command and arguments to run the MCP server", + "type": "array", + "items": { + "type": "string" + } + }, + "environment": { + "description": "Environment variables to set when running the MCP server", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "description": "Enable or disable the MCP server on startup", + "type": "boolean" + }, + "timeout": { + "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + "McpOAuthConfig": { + "type": "object", + "properties": { + "clientId": { + "description": "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", + "type": "string" + }, + "clientSecret": { + "description": "OAuth client secret (if required by the authorization server)", + "type": "string" + }, + "scope": { + "description": "OAuth scopes to request during authorization", + "type": "string" + } + }, + "additionalProperties": false + }, + "McpRemoteConfig": { + "type": "object", + "properties": { + "type": { + "description": "Type of MCP server connection", + "type": "string", + "const": "remote" + }, + "url": { + "description": "URL of the remote MCP server", + "type": "string" + }, + "enabled": { + "description": "Enable or disable the MCP server on startup", + "type": "boolean" + }, + "headers": { + "description": "Headers to send with the request", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "oauth": { + "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", + "anyOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfig" + }, + { + "type": "boolean", + "const": false + } + ] + }, + "timeout": { + "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "LayoutConfig": { + "description": "@deprecated Always uses stretch layout.", + "type": "string", + "enum": ["auto", "stretch"] + }, + "Config": { + "type": "object", + "properties": { + "$schema": { + "description": "JSON schema reference for configuration validation", + "type": "string" + }, + "theme": { + "description": "Theme name to use for the interface", + "type": "string" + }, + "keybinds": { + "$ref": "#/components/schemas/KeybindsConfig" + }, + "logLevel": { + "$ref": "#/components/schemas/LogLevel" + }, + "tui": { + "description": "TUI specific settings", + "type": "object", + "properties": { + "scroll_speed": { + "description": "TUI scroll speed", + "type": "number", + "minimum": 0.001 + }, + "scroll_acceleration": { + "description": "Scroll acceleration settings", + "type": "object", + "properties": { + "enabled": { + "description": "Enable scroll acceleration", + "type": "boolean" + } + }, + "required": ["enabled"] + }, + "diff_style": { + "description": "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", + "type": "string", + "enum": ["auto", "stacked"] + } + } + }, + "server": { + "$ref": "#/components/schemas/ServerConfig" + }, + "command": { + "description": "Command configuration, see https://opencode.ai/docs/commands", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "template": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "subtask": { + "type": "boolean" + } + }, + "required": ["template"] + } + }, + "skills": { + "description": "Additional skill folder paths", + "type": "object", + "properties": { + "paths": { + "description": "Additional paths to skill folders", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "watcher": { + "type": "object", + "properties": { + "ignore": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "plugin": { + "type": "array", + "items": { + "type": "string" + } + }, + "snapshot": { + "type": "boolean" + }, + "share": { + "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", + "type": "string", + "enum": ["manual", "auto", "disabled"] + }, + "autoshare": { + "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", + "type": "boolean" + }, + "autoupdate": { + "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "notify" + } + ] + }, + "disabled_providers": { + "description": "Disable providers that are loaded automatically", + "type": "array", + "items": { + "type": "string" + } + }, + "enabled_providers": { + "description": "When set, ONLY these providers will be enabled. All other providers will be ignored", + "type": "array", + "items": { + "type": "string" + } + }, + "model": { + "description": "Model to use in the format of provider/model, eg anthropic/claude-2", + "type": "string" + }, + "small_model": { + "description": "Small model to use for tasks like title generation in the format of provider/model", + "type": "string" + }, + "default_agent": { + "description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + "type": "string" + }, + "username": { + "description": "Custom username to display in conversations instead of system username", + "type": "string" + }, + "mode": { + "description": "@deprecated Use `agent` field instead.", + "type": "object", + "properties": { + "build": { + "$ref": "#/components/schemas/AgentConfig" + }, + "plan": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "agent": { + "description": "Agent configuration, see https://opencode.ai/docs/agents", + "type": "object", + "properties": { + "plan": { + "$ref": "#/components/schemas/AgentConfig" + }, + "build": { + "$ref": "#/components/schemas/AgentConfig" + }, + "general": { + "$ref": "#/components/schemas/AgentConfig" + }, + "explore": { + "$ref": "#/components/schemas/AgentConfig" + }, + "title": { + "$ref": "#/components/schemas/AgentConfig" + }, + "summary": { + "$ref": "#/components/schemas/AgentConfig" + }, + "compaction": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "provider": { + "description": "Custom provider configurations and model overrides", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/ProviderConfig" + } + }, + "mcp": { + "description": "MCP (Model Context Protocol) server configurations", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"], + "additionalProperties": false + } + ] + } + }, + "formatter": { + "anyOf": [ + { + "type": "boolean", + "const": false + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "environment": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + ] + }, + "lsp": { + "anyOf": [ + { + "type": "boolean", + "const": false + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "const": true + } + }, + "required": ["disabled"] + }, + { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled": { + "type": "boolean" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "initialization": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["command"] + } + ] + } + } + ] + }, + "instructions": { + "description": "Additional instruction files or patterns to include", + "type": "array", + "items": { + "type": "string" + } + }, + "layout": { + "$ref": "#/components/schemas/LayoutConfig" + }, + "permission": { + "$ref": "#/components/schemas/PermissionConfig" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "enterprise": { + "type": "object", + "properties": { + "url": { + "description": "Enterprise URL", + "type": "string" + } + } + }, + "compaction": { + "type": "object", + "properties": { + "auto": { + "description": "Enable automatic compaction when context is full (default: true)", + "type": "boolean" + }, + "prune": { + "description": "Enable pruning of old tool outputs (default: true)", + "type": "boolean" + } + } + }, + "experimental": { + "type": "object", + "properties": { + "disable_paste_summary": { + "type": "boolean" + }, + "batch_tool": { + "description": "Enable the batch tool", + "type": "boolean" + }, + "openTelemetry": { + "description": "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", + "type": "boolean" + }, + "primary_tools": { + "description": "Tools that should only be available to primary agents.", + "type": "array", + "items": { + "type": "string" + } + }, + "continue_loop_on_deny": { + "description": "Continue the agent loop when a tool call is denied", + "type": "boolean" + }, + "mcp_timeout": { + "description": "Timeout in milliseconds for model context protocol (MCP) requests", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + } + } + }, + "additionalProperties": false + }, + "BadRequestError": { + "type": "object", + "properties": { + "data": {}, + "errors": { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "success": { + "type": "boolean", + "const": false + } + }, + "required": ["data", "errors", "success"] + }, + "OAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" + } + }, + "required": ["type", "refresh", "access", "expires"] + }, + "ApiAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "api" + }, + "key": { + "type": "string" + } + }, + "required": ["type", "key"] + }, + "WellKnownAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "wellknown" + }, + "key": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": ["type", "key", "token"] + }, + "Auth": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" + }, + { + "$ref": "#/components/schemas/ApiAuth" + }, + { + "$ref": "#/components/schemas/WellKnownAuth" + } + ] + }, + "NotFoundError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "NotFoundError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["name", "data"] + }, + "Model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "api": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "npm": { + "type": "string" + } + }, + "required": ["id", "url", "npm"] + }, + "name": { + "type": "string" + }, + "family": { + "type": "string" + }, + "capabilities": { + "type": "object", + "properties": { + "temperature": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "attachment": { + "type": "boolean" + }, + "toolcall": { + "type": "boolean" + }, + "input": { + "type": "object", + "properties": { + "text": { + "type": "boolean" + }, + "audio": { + "type": "boolean" + }, + "image": { + "type": "boolean" + }, + "video": { + "type": "boolean" + }, + "pdf": { + "type": "boolean" + } + }, + "required": ["text", "audio", "image", "video", "pdf"] + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "boolean" + }, + "audio": { + "type": "boolean" + }, + "image": { + "type": "boolean" + }, + "video": { + "type": "boolean" + }, + "pdf": { + "type": "boolean" + } + }, + "required": ["text", "audio", "image", "video", "pdf"] + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": ["reasoning_content", "reasoning_details"] + } + }, + "required": ["field"] + } + ] + } + }, + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"] + }, + "experimentalOver200K": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "cache"] + } + }, + "required": ["input", "output", "cache"] + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": ["context", "output"] + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "release_date": { + "type": "string" + }, + "variants": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + } + }, + "required": [ + "id", + "providerID", + "api", + "name", + "capabilities", + "cost", + "limit", + "status", + "options", + "headers", + "release_date" + ] + }, + "Provider": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string", + "enum": ["env", "config", "custom", "api"] + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "key": { + "type": "string" + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Model" + } + } + }, + "required": ["id", "name", "source", "env", "options", "models"] + }, + "ToolIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "ToolListItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": {} + }, + "required": ["id", "description", "parameters"] + }, + "ToolList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolListItem" + } + }, + "Worktree": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": ["name", "branch", "directory"] + }, + "WorktreeCreateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "startCommand": { + "description": "Additional startup script to run after the project's start command", + "type": "string" + } + } + }, + "WorktreeRemoveInput": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"] + }, + "WorktreeResetInput": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"] + }, + "McpResource": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "client": { + "type": "string" + } + }, + "required": ["name", "uri", "client"] + }, + "TextPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": ["start"] + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["type", "text"] + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"] + }, + "AgentPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "agent" + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["value", "start", "end"] + } + }, + "required": ["type", "name"] + }, + "SubtaskPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "subtask" + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"] + }, + "command": { + "type": "string" + } + }, + "required": ["type", "prompt", "description", "agent"] + }, + "ProviderAuthMethod": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "const": "oauth" + }, + { + "type": "string", + "const": "api" + } + ] + }, + "label": { + "type": "string" + } + }, + "required": ["type", "label"] + }, + "ProviderAuthAuthorization": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "anyOf": [ + { + "type": "string", + "const": "auto" + }, + { + "type": "string", + "const": "code" + } + ] + }, + "instructions": { + "type": "string" + } + }, + "required": ["url", "method", "instructions"] + }, + "Symbol": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "number" + }, + "location": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + } + }, + "required": ["uri", "range"] + } + }, + "required": ["name", "kind", "location"] + }, + "FileNode": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "absolute": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file", "directory"] + }, + "ignored": { + "type": "boolean" + } + }, + "required": ["name", "path", "absolute", "type", "ignored"] + }, + "FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text", "binary"] + }, + "content": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "patch": { + "type": "object", + "properties": { + "oldFileName": { + "type": "string" + }, + "newFileName": { + "type": "string" + }, + "oldHeader": { + "type": "string" + }, + "newHeader": { + "type": "string" + }, + "hunks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldStart": { + "type": "number" + }, + "oldLines": { + "type": "number" + }, + "newStart": { + "type": "number" + }, + "newLines": { + "type": "number" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] + } + }, + "index": { + "type": "string" + } + }, + "required": ["oldFileName", "newFileName", "hunks"] + }, + "encoding": { + "type": "string", + "const": "base64" + }, + "mimeType": { + "type": "string" + } + }, + "required": ["type", "content"] + }, + "File": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "added": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "removed": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] + } + }, + "required": ["path", "added", "removed", "status"] + }, + "MCPStatusConnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "connected" + } + }, + "required": ["status"] + }, + "MCPStatusDisabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "disabled" + } + }, + "required": ["status"] + }, + "MCPStatusFailed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "failed" + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"] + }, + "MCPStatusNeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "needs_auth" + } + }, + "required": ["status"] + }, + "MCPStatusNeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "needs_client_registration" + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"] + }, + "MCPStatus": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPStatusConnected" + }, + { + "$ref": "#/components/schemas/MCPStatusDisabled" + }, + { + "$ref": "#/components/schemas/MCPStatusFailed" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsAuth" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" + } + ] + }, + "Path": { + "type": "object", + "properties": { + "home": { + "type": "string" + }, + "state": { + "type": "string" + }, + "config": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": ["home", "state", "config", "worktree", "directory"] + }, + "VcsInfo": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "required": ["branch"] + }, + "Command": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "source": { + "type": "string", + "enum": ["command", "mcp", "skill"] + }, + "template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "subtask": { + "type": "boolean" + }, + "hints": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "template", "hints"] + }, + "Agent": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["subagent", "primary", "all"] + }, + "native": { + "type": "boolean" + }, + "hidden": { + "type": "boolean" + }, + "topP": { + "type": "number" + }, + "temperature": { + "type": "number" + }, + "color": { + "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "model": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + } + }, + "required": ["modelID", "providerID"] + }, + "variant": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "steps": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["name", "mode", "permission", "options"] + }, + "LSPStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "root": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "const": "connected" + }, + { + "type": "string", + "const": "error" + } + ] + } + }, + "required": ["id", "name", "root", "status"] + }, + "FormatterStatus": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + } + }, + "required": ["name", "extensions", "enabled"] + } + } + } +} diff --git a/resources/agent-schemas/src/opencode.ts b/resources/agent-schemas/src/opencode.ts index 6f1e6a0..d10c329 100644 --- a/resources/agent-schemas/src/opencode.ts +++ b/resources/agent-schemas/src/opencode.ts @@ -1,9 +1,13 @@ +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; import { fetchWithCache } from "./cache.js"; import { createNormalizedSchema, openApiToJsonSchema, type NormalizedSchema } from "./normalize.js"; import type { JSONSchema7 } from "json-schema"; -const OPENAPI_URL = - "https://raw.githubusercontent.com/sst/opencode/dev/packages/sdk/openapi.json"; +const OPENAPI_URLS = [ + "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/sdk/openapi.json", + "https://raw.githubusercontent.com/sst/opencode/dev/packages/sdk/openapi.json", +]; // Key schemas we want to extract const TARGET_SCHEMAS = [ @@ -19,16 +23,40 @@ const TARGET_SCHEMAS = [ "ErrorPart", ]; +const OPENAPI_ARTIFACT_DIR = join(import.meta.dirname, "..", "artifacts", "openapi"); +const OPENAPI_ARTIFACT_PATH = join(OPENAPI_ARTIFACT_DIR, "opencode.json"); + interface OpenAPISpec { components?: { schemas?: Record<string, unknown>; }; } +function writeOpenApiArtifact(specText: string): void { + if (!existsSync(OPENAPI_ARTIFACT_DIR)) { + mkdirSync(OPENAPI_ARTIFACT_DIR, { recursive: true }); + } + writeFileSync(OPENAPI_ARTIFACT_PATH, specText); + console.log(` [wrote] ${OPENAPI_ARTIFACT_PATH}`); +} + export async function extractOpenCodeSchema(): Promise<NormalizedSchema> { console.log("Extracting OpenCode schema from OpenAPI spec..."); - const specText = await fetchWithCache(OPENAPI_URL); + let specText: string | null = null; + let lastError: Error | null = null; + for (const url of OPENAPI_URLS) { + try { + specText = await fetchWithCache(url); + break; + } catch (error) { + lastError = error as Error; + } + } + if (!specText) { + throw lastError ?? new Error("Failed to fetch OpenCode OpenAPI spec"); + } + writeOpenApiArtifact(specText); const spec: OpenAPISpec = JSON.parse(specText); if (!spec.components?.schemas) { diff --git a/server/packages/sandbox-agent/Cargo.toml b/server/packages/sandbox-agent/Cargo.toml index 028362a..5f45ad0 100644 --- a/server/packages/sandbox-agent/Cargo.toml +++ b/server/packages/sandbox-agent/Cargo.toml @@ -25,6 +25,7 @@ futures.workspace = true reqwest.workspace = true dirs.workspace = true time.workspace = true +chrono.workspace = true tokio.workspace = true tokio-stream.workspace = true tower-http.workspace = true @@ -34,11 +35,15 @@ tracing.workspace = true tracing-logfmt.workspace = true tracing-subscriber.workspace = true include_dir.workspace = true +base64.workspace = true tempfile = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] } + [dev-dependencies] http-body-util.workspace = true insta.workspace = true diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index 098d0b3..8c11343 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -2,6 +2,8 @@ mod agent_server_logs; pub mod credentials; +pub mod opencode_compat; pub mod router; +pub mod server_logs; pub mod telemetry; pub mod ui; diff --git a/server/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs index 92587c2..e3e6540 100644 --- a/server/packages/sandbox-agent/src/main.rs +++ b/server/packages/sandbox-agent/src/main.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; +use std::process::{Child, Command as ProcessCommand, Stdio}; use std::sync::Arc; +use std::time::{Duration, Instant}; use clap::{Args, Parser, Subcommand}; @@ -20,6 +22,7 @@ use sandbox_agent::router::{ AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse, SessionListResponse, }; +use sandbox_agent::server_logs::ServerLogs; use sandbox_agent::telemetry; use sandbox_agent::ui; use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; @@ -28,7 +31,7 @@ use sandbox_agent_agent_management::credentials::{ ProviderCredentials, }; use serde::Serialize; -use serde_json::Value; +use serde_json::{json, Value}; use thiserror::Error; use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; @@ -36,6 +39,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte const API_PREFIX: &str = "/v1"; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_PORT: u16 = 2468; +const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); #[derive(Parser, Debug)] #[command(name = "sandbox-agent", bin_name = "sandbox-agent")] @@ -58,6 +62,8 @@ enum Command { Server(ServerArgs), /// Call the HTTP API without writing client code. Api(ApiArgs), + /// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session. + Opencode(OpencodeArgs), /// Install or reinstall an agent without running the server. InstallAgent(InstallAgentArgs), /// Inspect locally discovered credentials. @@ -94,6 +100,21 @@ struct ApiArgs { command: ApiCommand, } +#[derive(Args, Debug)] +struct OpencodeArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, + + #[arg(long)] + session_title: Option<String>, + + #[arg(long)] + opencode_bin: Option<PathBuf>, +} + #[derive(Args, Debug)] struct CredentialsArgs { #[command(subcommand)] @@ -349,8 +370,11 @@ enum CliError { } fn main() { - init_logging(); let cli = Cli::parse(); + if let Err(err) = init_logging(&cli) { + eprintln!("failed to init logging: {err}"); + std::process::exit(1); + } let result = match &cli.command { Command::Server(args) => run_server(&cli, args), @@ -363,7 +387,11 @@ fn main() { } } -fn init_logging() { +fn init_logging(cli: &Cli) -> Result<(), CliError> { + if matches!(cli.command, Command::Server(_)) { + maybe_redirect_server_logs(); + } + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::registry() .with(filter) @@ -373,6 +401,7 @@ fn init_logging() { .with_writer(std::io::stderr), ) .init(); + Ok(()) } fn run_server(cli: &Cli, server: &ServerArgs) -> Result<(), CliError> { @@ -434,12 +463,33 @@ fn default_install_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin")) } +fn default_server_log_dir() -> PathBuf { + if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") { + return PathBuf::from(dir); + } + dirs::data_dir() + .map(|dir| dir.join("sandbox-agent").join("logs")) + .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs")) +} + +fn maybe_redirect_server_logs() { + if std::env::var("SANDBOX_AGENT_LOG_STDOUT").is_ok() { + return; + } + + let log_dir = default_server_log_dir(); + if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() { + eprintln!("failed to redirect logs: {err}"); + } +} + fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> { match command { Command::Server(_) => Err(CliError::Server( "server subcommand must be invoked as `sandbox-agent server`".to_string(), )), Command::Api(subcommand) => run_api(&subcommand.command, cli), + Command::Opencode(args) => run_opencode(cli, args), Command::InstallAgent(args) => install_agent_local(args), Command::Credentials(subcommand) => run_credentials(&subcommand.command), } @@ -452,6 +502,53 @@ fn run_api(command: &ApiCommand, cli: &Cli) -> Result<(), CliError> { } } +fn run_opencode(cli: &Cli, args: &OpencodeArgs) -> Result<(), CliError> { + write_stderr_line("experimental: opencode subcommand may change without notice")?; + + let token = if cli.no_token { + None + } else { + Some(cli.token.clone().ok_or(CliError::MissingToken)?) + }; + + let mut server_child = spawn_sandbox_agent_server(cli, args, token.as_deref())?; + let base_url = format!("http://{}:{}", args.host, args.port); + wait_for_health(&mut server_child, &base_url, token.as_deref())?; + + let session_id = create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?; + write_stdout_line(&format!("OpenCode session: {session_id}"))?; + + let attach_url = format!("{base_url}/opencode"); + let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref()); + let mut opencode_cmd = ProcessCommand::new(opencode_bin); + opencode_cmd + .arg("attach") + .arg(&attach_url) + .arg("--session") + .arg(&session_id) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(token) = token.as_deref() { + opencode_cmd.arg("--password").arg(token); + } + + let status = opencode_cmd.status().map_err(|err| { + terminate_child(&mut server_child); + CliError::Server(format!("failed to start opencode: {err}")) + })?; + + terminate_child(&mut server_child); + + if !status.success() { + return Err(CliError::Server(format!( + "opencode exited with status {status}" + ))); + } + + Ok(()) +} + fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> { match command { AgentsCommand::List(args) => { @@ -607,6 +704,113 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { } } +fn spawn_sandbox_agent_server( + cli: &Cli, + args: &OpencodeArgs, + token: Option<&str>, +) -> Result<Child, CliError> { + let exe = std::env::current_exe()?; + let mut cmd = ProcessCommand::new(exe); + cmd.arg("server") + .arg("--host") + .arg(&args.host) + .arg("--port") + .arg(args.port.to_string()) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + if cli.no_token { + cmd.arg("--no-token"); + } else if let Some(token) = token { + cmd.arg("--token").arg(token); + } + + cmd.spawn().map_err(CliError::from) +} + +fn wait_for_health( + server_child: &mut Child, + base_url: &str, + token: Option<&str>, +) -> Result<(), CliError> { + let client = HttpClient::builder().build()?; + let deadline = Instant::now() + Duration::from_secs(30); + + while Instant::now() < deadline { + if let Some(status) = server_child.try_wait()? { + return Err(CliError::Server(format!( + "sandbox-agent exited before becoming healthy ({status})" + ))); + } + + let url = format!("{base_url}/v1/health"); + let mut request = client.get(&url); + if let Some(token) = token { + request = request.bearer_auth(token); + } + match request.send() { + Ok(response) if response.status().is_success() => return Ok(()), + _ => { + std::thread::sleep(Duration::from_millis(200)); + } + } + } + + Err(CliError::Server( + "timed out waiting for sandbox-agent health".to_string(), + )) +} + +fn create_opencode_session( + base_url: &str, + token: Option<&str>, + title: Option<&str>, +) -> Result<String, CliError> { + let client = HttpClient::builder().build()?; + let url = format!("{base_url}/opencode/session"); + let body = if let Some(title) = title { + json!({ "title": title }) + } else { + json!({}) + }; + let mut request = client.post(&url).json(&body); + if let Ok(directory) = std::env::current_dir() { + request = request.header("x-opencode-directory", directory.to_string_lossy().to_string()); + } + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send()?; + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + print_error_body(&text)?; + return Err(CliError::HttpStatus(status)); + } + let body: Value = serde_json::from_str(&text)?; + let session_id = body + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?; + Ok(session_id.to_string()) +} + +fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> PathBuf { + if let Some(path) = explicit { + return path.clone(); + } + if let Ok(path) = std::env::var("OPENCODE_BIN") { + return PathBuf::from(path); + } + PathBuf::from("opencode") +} + +fn terminate_child(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> { match command { CredentialsCommand::Extract(args) => { diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs new file mode 100644 index 0000000..55b7050 --- /dev/null +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -0,0 +1,4274 @@ +//! OpenCode-compatible API handlers mounted under `/opencode`. +//! +//! These endpoints implement the full OpenCode OpenAPI surface. Most routes are +//! stubbed responses with deterministic helpers for snapshot testing. A minimal +//! in-memory state tracks sessions/messages/ptys to keep behavior coherent. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::str::FromStr; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::sse::{Event, KeepAlive}; +use axum::response::{IntoResponse, Sse}; +use axum::routing::{get, patch, post, put}; +use axum::{Json, Router}; +use futures::stream; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::{broadcast, Mutex}; +use tokio::time::interval; +use utoipa::{IntoParams, OpenApi, ToSchema}; + +use crate::router::{AppState, CreateSessionRequest, PermissionReply}; +use sandbox_agent_error::SandboxError; +use sandbox_agent_agent_management::agents::AgentId; +use sandbox_agent_universal_agent_schema::{ + ContentPart, ItemDeltaData, ItemEventData, ItemKind, ItemRole, UniversalEvent, UniversalEventData, + UniversalEventType, UniversalItem, PermissionEventData, PermissionStatus, QuestionEventData, + QuestionStatus, FileAction, ItemStatus, +}; + +static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); +static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); +static PART_COUNTER: AtomicU64 = AtomicU64::new(1); +static PTY_COUNTER: AtomicU64 = AtomicU64::new(1); +static PROJECT_COUNTER: AtomicU64 = AtomicU64::new(1); +const OPENCODE_PROVIDER_ID: &str = "sandbox-agent"; +const OPENCODE_PROVIDER_NAME: &str = "Sandbox Agent"; +const OPENCODE_DEFAULT_MODEL_ID: &str = "mock"; +const OPENCODE_DEFAULT_AGENT_MODE: &str = "build"; + +#[derive(Clone, Debug)] +struct OpenCodeCompatConfig { + fixed_time_ms: Option<i64>, + fixed_directory: Option<String>, + fixed_worktree: Option<String>, + fixed_home: Option<String>, + fixed_state: Option<String>, + fixed_config: Option<String>, + fixed_branch: Option<String>, +} + +impl OpenCodeCompatConfig { + fn from_env() -> Self { + Self { + fixed_time_ms: std::env::var("OPENCODE_COMPAT_FIXED_TIME_MS") + .ok() + .and_then(|value| value.parse::<i64>().ok()), + fixed_directory: std::env::var("OPENCODE_COMPAT_DIRECTORY").ok(), + fixed_worktree: std::env::var("OPENCODE_COMPAT_WORKTREE").ok(), + fixed_home: std::env::var("OPENCODE_COMPAT_HOME").ok(), + fixed_state: std::env::var("OPENCODE_COMPAT_STATE").ok(), + fixed_config: std::env::var("OPENCODE_COMPAT_CONFIG").ok(), + fixed_branch: std::env::var("OPENCODE_COMPAT_BRANCH").ok(), + } + } + + fn now_ms(&self) -> i64 { + if let Some(value) = self.fixed_time_ms { + return value; + } + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } +} + +#[derive(Clone, Debug)] +struct OpenCodeSessionRecord { + id: String, + slug: String, + project_id: String, + directory: String, + parent_id: Option<String>, + title: String, + version: String, + created_at: i64, + updated_at: i64, + share_url: Option<String>, +} + +impl OpenCodeSessionRecord { + fn to_value(&self) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(self.id)); + map.insert("slug".to_string(), json!(self.slug)); + map.insert("projectID".to_string(), json!(self.project_id)); + map.insert("directory".to_string(), json!(self.directory)); + map.insert("title".to_string(), json!(self.title)); + map.insert("version".to_string(), json!(self.version)); + map.insert( + "time".to_string(), + json!({ + "created": self.created_at, + "updated": self.updated_at, + }), + ); + if let Some(parent_id) = &self.parent_id { + map.insert("parentID".to_string(), json!(parent_id)); + } + if let Some(url) = &self.share_url { + map.insert("share".to_string(), json!({"url": url})); + } + Value::Object(map) + } +} + +#[derive(Clone, Debug)] +struct OpenCodeMessageRecord { + info: Value, + parts: Vec<Value>, +} + +#[derive(Clone, Debug)] +struct OpenCodePtyRecord { + id: String, + title: String, + command: String, + args: Vec<String>, + cwd: String, + status: String, + pid: i64, +} + +impl OpenCodePtyRecord { + fn to_value(&self) -> Value { + json!({ + "id": self.id, + "title": self.title, + "command": self.command, + "args": self.args, + "cwd": self.cwd, + "status": self.status, + "pid": self.pid, + }) + } +} + +#[derive(Clone, Debug)] +struct OpenCodePermissionRecord { + id: String, + session_id: String, + permission: String, + patterns: Vec<String>, + metadata: Value, + always: Vec<String>, + tool: Option<Value>, +} + +impl OpenCodePermissionRecord { + fn to_value(&self) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(self.id)); + map.insert("sessionID".to_string(), json!(self.session_id)); + map.insert("permission".to_string(), json!(self.permission)); + map.insert("patterns".to_string(), json!(self.patterns)); + map.insert("metadata".to_string(), self.metadata.clone()); + map.insert("always".to_string(), json!(self.always)); + if let Some(tool) = &self.tool { + map.insert("tool".to_string(), tool.clone()); + } + Value::Object(map) + } +} + +#[derive(Clone, Debug)] +struct OpenCodeQuestionRecord { + id: String, + session_id: String, + questions: Vec<Value>, + tool: Option<Value>, +} + +impl OpenCodeQuestionRecord { + fn to_value(&self) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(self.id)); + map.insert("sessionID".to_string(), json!(self.session_id)); + map.insert("questions".to_string(), json!(self.questions)); + if let Some(tool) = &self.tool { + map.insert("tool".to_string(), tool.clone()); + } + Value::Object(map) + } +} + +#[derive(Default, Clone)] +struct OpenCodeSessionRuntime { + last_user_message_id: Option<String>, + last_agent: Option<String>, + last_model_provider: Option<String>, + last_model_id: Option<String>, + session_agent_id: Option<String>, + session_provider_id: Option<String>, + session_model_id: Option<String>, + message_id_for_item: HashMap<String, String>, + text_by_message: HashMap<String, String>, + part_id_by_message: HashMap<String, String>, + tool_part_by_call: HashMap<String, String>, + tool_message_by_call: HashMap<String, String>, +} + +pub struct OpenCodeState { + config: OpenCodeCompatConfig, + default_project_id: String, + sessions: Mutex<HashMap<String, OpenCodeSessionRecord>>, + messages: Mutex<HashMap<String, Vec<OpenCodeMessageRecord>>>, + ptys: Mutex<HashMap<String, OpenCodePtyRecord>>, + permissions: Mutex<HashMap<String, OpenCodePermissionRecord>>, + questions: Mutex<HashMap<String, OpenCodeQuestionRecord>>, + session_runtime: Mutex<HashMap<String, OpenCodeSessionRuntime>>, + session_streams: Mutex<HashMap<String, bool>>, + event_broadcaster: broadcast::Sender<Value>, +} + +impl OpenCodeState { + pub fn new() -> Self { + let (event_broadcaster, _) = broadcast::channel(256); + let project_id = format!("proj_{}", PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)); + Self { + config: OpenCodeCompatConfig::from_env(), + default_project_id: project_id, + sessions: Mutex::new(HashMap::new()), + messages: Mutex::new(HashMap::new()), + ptys: Mutex::new(HashMap::new()), + permissions: Mutex::new(HashMap::new()), + questions: Mutex::new(HashMap::new()), + session_runtime: Mutex::new(HashMap::new()), + session_streams: Mutex::new(HashMap::new()), + event_broadcaster, + } + } + + pub fn subscribe(&self) -> broadcast::Receiver<Value> { + self.event_broadcaster.subscribe() + } + + pub fn emit_event(&self, event: Value) { + let _ = self.event_broadcaster.send(event); + } + + fn now_ms(&self) -> i64 { + self.config.now_ms() + } + + fn directory_for(&self, headers: &HeaderMap, query: Option<&String>) -> String { + if let Some(value) = query { + return value.clone(); + } + if let Some(value) = self + .config + .fixed_directory + .as_ref() + .cloned() + .or_else(|| { + headers + .get("x-opencode-directory") + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_string()) + }) + { + return value; + } + std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(|v| v.to_string())) + .unwrap_or_else(|| ".".to_string()) + } + + fn worktree_for(&self, directory: &str) -> String { + self.config + .fixed_worktree + .clone() + .unwrap_or_else(|| directory.to_string()) + } + + fn home_dir(&self) -> String { + self.config + .fixed_home + .clone() + .or_else(|| std::env::var("HOME").ok()) + .unwrap_or_else(|| "/".to_string()) + } + + fn state_dir(&self) -> String { + self.config + .fixed_state + .clone() + .unwrap_or_else(|| format!("{}/.local/state/opencode", self.home_dir())) + } + + async fn ensure_session(&self, session_id: &str, directory: String) -> Value { + let mut sessions = self.sessions.lock().await; + if let Some(existing) = sessions.get(session_id) { + return existing.to_value(); + } + + let now = self.now_ms(); + let record = OpenCodeSessionRecord { + id: session_id.to_string(), + slug: format!("session-{}", session_id), + project_id: self.default_project_id.clone(), + directory, + parent_id: None, + title: format!("Session {}", session_id), + version: "0".to_string(), + created_at: now, + updated_at: now, + share_url: None, + }; + let value = record.to_value(); + sessions.insert(session_id.to_string(), record); + drop(sessions); + + self.emit_event(session_event("session.created", &value)); + value + } + + fn config_dir(&self) -> String { + self.config + .fixed_config + .clone() + .unwrap_or_else(|| format!("{}/.config/opencode", self.home_dir())) + } + + fn branch_name(&self) -> String { + self.config + .fixed_branch + .clone() + .unwrap_or_else(|| "main".to_string()) + } + + async fn update_runtime( + &self, + session_id: &str, + update: impl FnOnce(&mut OpenCodeSessionRuntime), + ) -> OpenCodeSessionRuntime { + let mut runtimes = self.session_runtime.lock().await; + let entry = runtimes + .entry(session_id.to_string()) + .or_insert_with(OpenCodeSessionRuntime::default); + update(entry); + entry.clone() + } +} + +/// Combined app state with OpenCode state. +pub struct OpenCodeAppState { + pub inner: Arc<AppState>, + pub opencode: Arc<OpenCodeState>, +} + +impl OpenCodeAppState { + pub fn new(inner: Arc<AppState>) -> Arc<Self> { + Arc::new(Self { + inner, + opencode: Arc::new(OpenCodeState::new()), + }) + } +} + +async fn ensure_backing_session( + state: &Arc<OpenCodeAppState>, + session_id: &str, + agent: &str, +) -> Result<(), SandboxError> { + let request = CreateSessionRequest { + agent: agent.to_string(), + agent_mode: None, + permission_mode: None, + model: None, + variant: None, + agent_version: None, + }; + match state + .inner + .session_manager() + .create_session(session_id.to_string(), request) + .await + { + Ok(_) => Ok(()), + Err(SandboxError::SessionAlreadyExists { .. }) => Ok(()), + Err(err) => Err(err), + } +} + +async fn ensure_session_stream(state: Arc<OpenCodeAppState>, session_id: String) { + let should_spawn = { + let mut streams = state.opencode.session_streams.lock().await; + if streams.contains_key(&session_id) { + false + } else { + streams.insert(session_id.clone(), true); + true + } + }; + if !should_spawn { + return; + } + + tokio::spawn(async move { + let subscription = match state + .inner + .session_manager() + .subscribe(&session_id, 0) + .await + { + Ok(subscription) => subscription, + Err(_) => { + let mut streams = state.opencode.session_streams.lock().await; + streams.remove(&session_id); + return; + } + }; + + for event in subscription.initial_events { + apply_universal_event(state.clone(), event).await; + } + let mut receiver = subscription.receiver; + loop { + match receiver.recv().await { + Ok(event) => { + apply_universal_event(state.clone(), event).await; + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + let mut streams = state.opencode.session_streams.lock().await; + streams.remove(&session_id); + }); +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct OpenCodeCreateSessionRequest { + title: Option<String>, + #[serde(rename = "parentID")] + parent_id: Option<String>, + #[schema(value_type = String)] + permission: Option<Value>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct OpenCodeUpdateSessionRequest { + title: Option<String>, +} + +#[derive(Debug, Deserialize, IntoParams)] +struct DirectoryQuery { + directory: Option<String>, +} + +#[derive(Debug, Deserialize, IntoParams)] +struct ToolQuery { + directory: Option<String>, + provider: Option<String>, + model: Option<String>, +} + +#[derive(Debug, Deserialize, IntoParams)] +struct FindTextQuery { + directory: Option<String>, + pattern: Option<String>, +} + +#[derive(Debug, Deserialize, IntoParams)] +struct FindFilesQuery { + directory: Option<String>, + query: Option<String>, +} + +#[derive(Debug, Deserialize, IntoParams)] +struct FindSymbolsQuery { + directory: Option<String>, + query: Option<String>, +} + +#[derive(Debug, Deserialize, IntoParams)] +struct FileContentQuery { + directory: Option<String>, + path: Option<String>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct SessionMessageRequest { + #[schema(value_type = Vec<String>)] + parts: Option<Vec<Value>>, + #[serde(rename = "messageID")] + message_id: Option<String>, + agent: Option<String>, + #[schema(value_type = String)] + model: Option<Value>, + system: Option<String>, + variant: Option<String>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct SessionCommandRequest { + command: Option<String>, + arguments: Option<String>, + #[serde(rename = "messageID")] + message_id: Option<String>, + agent: Option<String>, + model: Option<String>, + variant: Option<String>, + #[schema(value_type = Vec<String>)] + parts: Option<Vec<Value>>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct SessionShellRequest { + command: Option<String>, + agent: Option<String>, + #[schema(value_type = String)] + model: Option<Value>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct SessionSummarizeRequest { + #[serde(rename = "providerID")] + provider_id: Option<String>, + #[serde(rename = "modelID")] + model_id: Option<String>, + auto: Option<bool>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +struct PermissionReplyRequest { + response: Option<String>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct PermissionGlobalReplyRequest { + reply: Option<String>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct QuestionReplyBody { + answers: Option<Vec<Vec<String>>>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct PtyCreateRequest { + command: Option<String>, + args: Option<Vec<String>>, + cwd: Option<String>, + title: Option<String>, +} + +fn next_id(prefix: &str, counter: &AtomicU64) -> String { + let id = counter.fetch_add(1, Ordering::Relaxed); + format!("{}{}", prefix, id) +} + +fn available_agent_ids() -> Vec<AgentId> { + vec![ + AgentId::Claude, + AgentId::Codex, + AgentId::Opencode, + AgentId::Amp, + AgentId::Mock, + ] +} + +fn default_agent_id() -> AgentId { + AgentId::Mock +} + +fn default_agent_mode() -> &'static str { + OPENCODE_DEFAULT_AGENT_MODE +} + +fn resolve_agent_from_model(provider_id: &str, model_id: &str) -> Option<AgentId> { + if provider_id == OPENCODE_PROVIDER_ID { + AgentId::parse(model_id) + } else { + None + } +} + +fn normalize_agent_mode(agent: Option<String>) -> String { + agent.filter(|value| !value.is_empty()) + .unwrap_or_else(|| default_agent_mode().to_string()) +} + +async fn resolve_session_agent( + state: &OpenCodeAppState, + session_id: &str, + requested_provider: Option<&str>, + requested_model: Option<&str>, +) -> (String, String, String) { + let mut provider_id = requested_provider + .filter(|value| !value.is_empty()) + .unwrap_or(OPENCODE_PROVIDER_ID) + .to_string(); + let mut model_id = requested_model + .filter(|value| !value.is_empty()) + .unwrap_or(OPENCODE_DEFAULT_MODEL_ID) + .to_string(); + let mut resolved_agent = resolve_agent_from_model(&provider_id, &model_id); + if resolved_agent.is_none() { + provider_id = OPENCODE_PROVIDER_ID.to_string(); + model_id = OPENCODE_DEFAULT_MODEL_ID.to_string(); + resolved_agent = Some(default_agent_id()); + } + + let mut resolved_agent_id: Option<String> = None; + let mut resolved_provider: Option<String> = None; + let mut resolved_model: Option<String> = None; + + state + .opencode + .update_runtime(session_id, |runtime| { + if runtime.session_agent_id.is_none() { + let agent = resolved_agent.unwrap_or_else(default_agent_id); + runtime.session_agent_id = Some(agent.as_str().to_string()); + runtime.session_provider_id = Some(provider_id.clone()); + runtime.session_model_id = Some(model_id.clone()); + } + resolved_agent_id = runtime.session_agent_id.clone(); + resolved_provider = runtime.session_provider_id.clone(); + resolved_model = runtime.session_model_id.clone(); + }) + .await; + + ( + resolved_agent_id.unwrap_or_else(|| default_agent_id().as_str().to_string()), + resolved_provider.unwrap_or(provider_id), + resolved_model.unwrap_or(model_id), + ) +} + +fn agent_display_name(agent: AgentId) -> &'static str { + match agent { + AgentId::Claude => "Claude", + AgentId::Codex => "Codex", + AgentId::Opencode => "OpenCode", + AgentId::Amp => "Amp", + AgentId::Mock => "Mock", + } +} + +fn model_config_entry(agent: AgentId) -> Value { + json!({ + "id": agent.as_str(), + "providerID": OPENCODE_PROVIDER_ID, + "api": { + "id": "sandbox-agent", + "url": "http://localhost", + "npm": "@sandbox-agent/sdk" + }, + "name": agent_display_name(agent), + "family": "sandbox-agent", + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 0, + "output": 0, + "cache": {"read": 0, "write": 0} + }, + "limit": { + "context": 128000, + "output": 4096 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2024-01-01", + "variants": {} + }) +} + +fn model_summary_entry(agent: AgentId) -> Value { + json!({ + "id": agent.as_str(), + "name": agent_display_name(agent), + "release_date": "2024-01-01", + "attachment": false, + "reasoning": true, + "temperature": true, + "tool_call": true, + "options": {}, + "limit": { + "context": 128000, + "output": 4096 + } + }) +} + +fn bad_request(message: &str) -> (StatusCode, Json<Value>) { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "data": {}, + "errors": [{"message": message}], + "success": false, + })), + ) +} + +fn not_found(message: &str) -> (StatusCode, Json<Value>) { + ( + StatusCode::NOT_FOUND, + Json(json!({ + "name": "NotFoundError", + "data": {"message": message}, + })), + ) +} + +fn internal_error(message: &str) -> (StatusCode, Json<Value>) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "data": {}, + "errors": [{"message": message}], + "success": false, + })), + ) +} + +fn sandbox_error_response(err: SandboxError) -> (StatusCode, Json<Value>) { + match err { + SandboxError::SessionNotFound { .. } => not_found("Session not found"), + SandboxError::InvalidRequest { message } => bad_request(&message), + other => internal_error(&other.to_string()), + } +} + +fn parse_permission_reply_value(value: Option<&str>) -> Result<PermissionReply, String> { + let value = value.unwrap_or("once").to_ascii_lowercase(); + match value.as_str() { + "once" | "allow" | "approve" => Ok(PermissionReply::Once), + "always" => Ok(PermissionReply::Always), + "reject" | "deny" => Ok(PermissionReply::Reject), + other => PermissionReply::from_str(other), + } +} + +fn bool_ok(value: bool) -> (StatusCode, Json<Value>) { + (StatusCode::OK, Json(json!(value))) +} + +fn build_user_message( + session_id: &str, + message_id: &str, + created_at: i64, + agent: &str, + provider_id: &str, + model_id: &str, +) -> Value { + json!({ + "id": message_id, + "sessionID": session_id, + "role": "user", + "time": {"created": created_at}, + "agent": agent, + "model": {"providerID": provider_id, "modelID": model_id}, + }) +} + +fn build_assistant_message( + session_id: &str, + message_id: &str, + parent_id: &str, + created_at: i64, + directory: &str, + worktree: &str, + agent: &str, + provider_id: &str, + model_id: &str, +) -> Value { + json!({ + "id": message_id, + "sessionID": session_id, + "role": "assistant", + "time": {"created": created_at}, + "parentID": parent_id, + "modelID": model_id, + "providerID": provider_id, + "mode": "default", + "agent": agent, + "path": {"cwd": directory, "root": worktree}, + "cost": 0, + "finish": "stop", + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": {"read": 0, "write": 0} + } + }) +} + +fn build_text_part(session_id: &str, message_id: &str, text: &str) -> Value { + json!({ + "id": next_id("part_", &PART_COUNTER), + "sessionID": session_id, + "messageID": message_id, + "type": "text", + "text": text, + }) +} + +fn part_id_from_input(input: &Value) -> String { + input + .get("id") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .unwrap_or_else(|| next_id("part_", &PART_COUNTER)) +} + +fn build_file_part(session_id: &str, message_id: &str, input: &Value) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(part_id_from_input(input))); + map.insert("sessionID".to_string(), json!(session_id)); + map.insert("messageID".to_string(), json!(message_id)); + map.insert("type".to_string(), json!("file")); + map.insert( + "mime".to_string(), + input + .get("mime") + .cloned() + .unwrap_or_else(|| json!("application/octet-stream")), + ); + map.insert( + "url".to_string(), + input.get("url").cloned().unwrap_or_else(|| json!("")), + ); + if let Some(filename) = input.get("filename") { + map.insert("filename".to_string(), filename.clone()); + } + if let Some(source) = input.get("source") { + map.insert("source".to_string(), source.clone()); + } + Value::Object(map) +} + +fn build_agent_part(session_id: &str, message_id: &str, input: &Value) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(part_id_from_input(input))); + map.insert("sessionID".to_string(), json!(session_id)); + map.insert("messageID".to_string(), json!(message_id)); + map.insert("type".to_string(), json!("agent")); + map.insert( + "name".to_string(), + input.get("name").cloned().unwrap_or_else(|| json!("")), + ); + if let Some(source) = input.get("source") { + map.insert("source".to_string(), source.clone()); + } + Value::Object(map) +} + +fn build_subtask_part(session_id: &str, message_id: &str, input: &Value) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(part_id_from_input(input))); + map.insert("sessionID".to_string(), json!(session_id)); + map.insert("messageID".to_string(), json!(message_id)); + map.insert("type".to_string(), json!("subtask")); + map.insert( + "prompt".to_string(), + input.get("prompt").cloned().unwrap_or_else(|| json!("")), + ); + map.insert( + "description".to_string(), + input + .get("description") + .cloned() + .unwrap_or_else(|| json!("")), + ); + map.insert( + "agent".to_string(), + input.get("agent").cloned().unwrap_or_else(|| json!("")), + ); + if let Some(model) = input.get("model") { + map.insert("model".to_string(), model.clone()); + } + if let Some(command) = input.get("command") { + map.insert("command".to_string(), command.clone()); + } + Value::Object(map) +} + +fn normalize_part(session_id: &str, message_id: &str, input: &Value) -> Value { + match input.get("type").and_then(|v| v.as_str()) { + Some("file") => build_file_part(session_id, message_id, input), + Some("agent") => build_agent_part(session_id, message_id, input), + Some("subtask") => build_subtask_part(session_id, message_id, input), + _ => build_text_part( + session_id, + message_id, + input + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(), + ), + } +} + +fn message_id_for_sequence(sequence: u64) -> String { + format!("msg_{:020}", sequence) +} + +fn unique_assistant_message_id( + runtime: &OpenCodeSessionRuntime, + parent_id: Option<&String>, + sequence: u64, +) -> String { + let base = match parent_id { + Some(parent) => format!("{parent}_assistant"), + None => message_id_for_sequence(sequence), + }; + if runtime.message_id_for_item.values().any(|id| id == &base) { + format!("{base}_{:020}", sequence) + } else { + base + } +} + + +fn extract_text_from_content(parts: &[ContentPart]) -> Option<String> { + let mut text = String::new(); + for part in parts { + match part { + ContentPart::Text { text: chunk } => { + text.push_str(chunk); + } + ContentPart::Json { json } => { + if let Ok(chunk) = serde_json::to_string(json) { + text.push_str(&chunk); + } + } + ContentPart::Status { label, detail } => { + text.push_str(label); + if let Some(detail) = detail { + if !detail.is_empty() { + text.push_str(": "); + text.push_str(detail); + } + } + } + _ => {} + } + } + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn build_text_part_with_id(session_id: &str, message_id: &str, part_id: &str, text: &str) -> Value { + json!({ + "id": part_id, + "sessionID": session_id, + "messageID": message_id, + "type": "text", + "text": text, + }) +} + +fn build_reasoning_part( + session_id: &str, + message_id: &str, + part_id: &str, + text: &str, + now: i64, +) -> Value { + json!({ + "id": part_id, + "sessionID": session_id, + "messageID": message_id, + "type": "reasoning", + "text": text, + "metadata": {}, + "time": {"start": now, "end": now}, + }) +} + +fn build_tool_part( + session_id: &str, + message_id: &str, + part_id: &str, + call_id: &str, + tool: &str, + state: Value, +) -> Value { + json!({ + "id": part_id, + "sessionID": session_id, + "messageID": message_id, + "type": "tool", + "callID": call_id, + "tool": tool, + "state": state, + "metadata": {}, + }) +} + +fn file_source_from_diff(path: &str, diff: &str) -> Value { + json!({ + "type": "file", + "path": path, + "text": { + "value": diff, + "start": 0, + "end": diff.len() as i64, + } + }) +} + +fn build_file_part_from_path( + session_id: &str, + message_id: &str, + path: &str, + mime: &str, + diff: Option<&str>, +) -> Value { + let mut map = serde_json::Map::new(); + map.insert("id".to_string(), json!(next_id("part_", &PART_COUNTER))); + map.insert("sessionID".to_string(), json!(session_id)); + map.insert("messageID".to_string(), json!(message_id)); + map.insert("type".to_string(), json!("file")); + map.insert("mime".to_string(), json!(mime)); + map.insert("url".to_string(), json!(format!("file://{}", path))); + map.insert("filename".to_string(), json!(path)); + if let Some(diff) = diff { + map.insert("source".to_string(), file_source_from_diff(path, diff)); + } + Value::Object(map) +} + +fn session_event(event_type: &str, session: &Value) -> Value { + json!({ + "type": event_type, + "properties": {"info": session} + }) +} + +fn message_event(event_type: &str, message: &Value) -> Value { + let session_id = message + .get("sessionID") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let mut props = serde_json::Map::new(); + props.insert("info".to_string(), message.clone()); + if let Some(session_id) = session_id { + props.insert("sessionID".to_string(), json!(session_id)); + } + Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("type".to_string(), json!(event_type)); + map.insert("properties".to_string(), Value::Object(props)); + map + }) +} + +fn part_event_with_delta(event_type: &str, part: &Value, delta: Option<&str>) -> Value { + let mut props = serde_json::Map::new(); + props.insert("part".to_string(), part.clone()); + if let Some(session_id) = part.get("sessionID").and_then(|v| v.as_str()) { + props.insert("sessionID".to_string(), json!(session_id)); + } + if let Some(message_id) = part.get("messageID").and_then(|v| v.as_str()) { + props.insert("messageID".to_string(), json!(message_id)); + } + if let Some(delta) = delta { + props.insert("delta".to_string(), json!(delta)); + } + Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("type".to_string(), json!(event_type)); + map.insert("properties".to_string(), Value::Object(props)); + map + }) +} + +fn part_event(event_type: &str, part: &Value) -> Value { + part_event_with_delta(event_type, part, None) +} + +fn emit_file_edited(state: &OpenCodeState, path: &str) { + state.emit_event(json!({ + "type": "file.edited", + "properties": {"file": path} + })); +} + +fn permission_event(event_type: &str, permission: &Value) -> Value { + json!({ + "type": event_type, + "properties": permission + }) +} + +fn question_event(event_type: &str, question: &Value) -> Value { + json!({ + "type": event_type, + "properties": question + }) +} + +fn message_id_from_info(info: &Value) -> Option<String> { + info.get("id").and_then(|v| v.as_str()).map(|v| v.to_string()) +} + +async fn upsert_message_info( + state: &OpenCodeState, + session_id: &str, + info: Value, +) -> Vec<Value> { + let mut messages = state.messages.lock().await; + let entry = messages.entry(session_id.to_string()).or_default(); + let message_id = message_id_from_info(&info); + if let Some(message_id) = message_id.clone() { + if let Some(existing) = entry + .iter_mut() + .find(|record| message_id_from_info(&record.info).as_deref() == Some(message_id.as_str())) + { + existing.info = info.clone(); + } else { + entry.push(OpenCodeMessageRecord { + info: info.clone(), + parts: Vec::new(), + }); + } + entry.sort_by(|a, b| { + let a_id = message_id_from_info(&a.info).unwrap_or_default(); + let b_id = message_id_from_info(&b.info).unwrap_or_default(); + a_id.cmp(&b_id) + }); + } + entry.iter().map(|record| record.info.clone()).collect() +} + +async fn upsert_message_part( + state: &OpenCodeState, + session_id: &str, + message_id: &str, + part: Value, +) { + let mut messages = state.messages.lock().await; + let entry = messages.entry(session_id.to_string()).or_default(); + let record = if let Some(record) = entry + .iter_mut() + .find(|record| message_id_from_info(&record.info).as_deref() == Some(message_id)) + { + record + } else { + entry.push(OpenCodeMessageRecord { + info: json!({"id": message_id, "sessionID": session_id, "role": "assistant", "time": {"created": 0}}), + parts: Vec::new(), + }); + entry.last_mut().expect("record just inserted") + }; + + let part_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(existing) = record + .parts + .iter_mut() + .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(part_id)) + { + *existing = part; + } else { + record.parts.push(part); + } + record.parts.sort_by(|a, b| { + let a_id = a.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let b_id = b.get("id").and_then(|v| v.as_str()).unwrap_or(""); + a_id.cmp(b_id) + }); +} + +async fn session_directory(state: &OpenCodeState, session_id: &str) -> String { + let sessions = state.sessions.lock().await; + if let Some(session) = sessions.get(session_id) { + return session.directory.clone(); + } + std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(|v| v.to_string())) + .unwrap_or_else(|| ".".to_string()) +} + +#[derive(Default)] +struct ToolContentInfo { + call_id: Option<String>, + tool_name: Option<String>, + arguments: Option<String>, + output: Option<String>, + file_refs: Vec<(String, FileAction, Option<String>)>, +} + +fn extract_tool_content(parts: &[ContentPart]) -> ToolContentInfo { + let mut info = ToolContentInfo::default(); + for part in parts { + match part { + ContentPart::ToolCall { + name, + arguments, + call_id, + } => { + info.call_id = Some(call_id.clone()); + info.tool_name = Some(name.clone()); + info.arguments = Some(arguments.clone()); + } + ContentPart::ToolResult { call_id, output } => { + info.call_id = Some(call_id.clone()); + info.output = Some(output.clone()); + } + ContentPart::FileRef { path, action, diff } => { + info.file_refs + .push((path.clone(), action.clone(), diff.clone())); + } + _ => {} + } + } + info +} + +fn tool_input_from_arguments(arguments: Option<&str>) -> Value { + let Some(arguments) = arguments else { + return json!({}); + }; + if let Ok(value) = serde_json::from_str::<Value>(arguments) { + if value.is_object() { + return value; + } + } + json!({ "arguments": arguments }) +} + +fn patterns_from_metadata(metadata: &Option<Value>) -> Vec<String> { + let mut patterns = Vec::new(); + let Some(metadata) = metadata else { + return patterns; + }; + if let Some(path) = metadata.get("path").and_then(|v| v.as_str()) { + patterns.push(path.to_string()); + } + if let Some(paths) = metadata.get("paths").and_then(|v| v.as_array()) { + for value in paths { + if let Some(path) = value.as_str() { + patterns.push(path.to_string()); + } + } + } + if let Some(patterns_value) = metadata.get("patterns").and_then(|v| v.as_array()) { + for value in patterns_value { + if let Some(pattern) = value.as_str() { + patterns.push(pattern.to_string()); + } + } + } + patterns +} + +async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEvent) { + match event.event_type { + UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => { + if let UniversalEventData::Item(ItemEventData { item }) = &event.data { + apply_item_event(state, event.clone(), item.clone()).await; + } + } + UniversalEventType::ItemDelta => { + if let UniversalEventData::ItemDelta(ItemDeltaData { + item_id, + native_item_id, + delta, + }) = &event.data + { + apply_item_delta( + state, + event.clone(), + item_id.clone(), + native_item_id.clone(), + delta.clone(), + ) + .await; + } + } + UniversalEventType::SessionEnded => { + let session_id = event.session_id.clone(); + state.opencode.emit_event(json!({ + "type": "session.status", + "properties": {"sessionID": session_id, "status": {"type": "idle"}} + })); + state.opencode.emit_event(json!({ + "type": "session.idle", + "properties": {"sessionID": event.session_id} + })); + } + UniversalEventType::PermissionRequested | UniversalEventType::PermissionResolved => { + if let UniversalEventData::Permission(permission) = &event.data { + apply_permission_event(state, event.clone(), permission.clone()).await; + } + } + UniversalEventType::QuestionRequested | UniversalEventType::QuestionResolved => { + if let UniversalEventData::Question(question) = &event.data { + apply_question_event(state, event.clone(), question.clone()).await; + } + } + UniversalEventType::Error => { + if let UniversalEventData::Error(error) = &event.data { + state.opencode.emit_event(json!({ + "type": "session.error", + "properties": { + "sessionID": event.session_id, + "error": { + "data": {"message": error.message}, + "code": error.code, + "details": error.details, + } + } + })); + } + } + _ => {} + } +} + +async fn apply_permission_event( + state: Arc<OpenCodeAppState>, + event: UniversalEvent, + permission: PermissionEventData, +) { + let session_id = event.session_id.clone(); + match permission.status { + PermissionStatus::Requested => { + let record = OpenCodePermissionRecord { + id: permission.permission_id.clone(), + session_id: session_id.clone(), + permission: permission.action.clone(), + patterns: patterns_from_metadata(&permission.metadata), + metadata: permission.metadata.clone().unwrap_or_else(|| json!({})), + always: Vec::new(), + tool: None, + }; + let value = record.to_value(); + let mut permissions = state.opencode.permissions.lock().await; + permissions.insert(record.id.clone(), record); + drop(permissions); + state.opencode.emit_event(permission_event("permission.asked", &value)); + } + PermissionStatus::Approved | PermissionStatus::Denied => { + let reply = match permission.status { + PermissionStatus::Approved => "once", + PermissionStatus::Denied => "reject", + PermissionStatus::Requested => "once", + }; + let event_value = json!({ + "sessionID": session_id, + "requestID": permission.permission_id, + "reply": reply, + }); + let mut permissions = state.opencode.permissions.lock().await; + permissions.remove(&permission.permission_id); + drop(permissions); + state + .opencode + .emit_event(permission_event("permission.replied", &event_value)); + } + } +} + +async fn apply_question_event( + state: Arc<OpenCodeAppState>, + event: UniversalEvent, + question: QuestionEventData, +) { + let session_id = event.session_id.clone(); + match question.status { + QuestionStatus::Requested => { + let options: Vec<Value> = question + .options + .iter() + .map(|option| { + json!({ + "label": option, + "description": "" + }) + }) + .collect(); + let question_info = json!({ + "header": "Question", + "question": question.prompt, + "options": options, + }); + let record = OpenCodeQuestionRecord { + id: question.question_id.clone(), + session_id: session_id.clone(), + questions: vec![question_info], + tool: None, + }; + let value = record.to_value(); + let mut questions = state.opencode.questions.lock().await; + questions.insert(record.id.clone(), record); + drop(questions); + state.opencode.emit_event(question_event("question.asked", &value)); + } + QuestionStatus::Answered => { + let answers = question + .response + .clone() + .map(|answer| vec![vec![answer]]) + .unwrap_or_else(|| Vec::<Vec<String>>::new()); + let event_value = json!({ + "sessionID": session_id, + "requestID": question.question_id, + "answers": answers, + }); + let mut questions = state.opencode.questions.lock().await; + questions.remove(&question.question_id); + drop(questions); + state + .opencode + .emit_event(question_event("question.replied", &event_value)); + } + QuestionStatus::Rejected => { + let event_value = json!({ + "sessionID": session_id, + "requestID": question.question_id, + }); + let mut questions = state.opencode.questions.lock().await; + questions.remove(&question.question_id); + drop(questions); + state + .opencode + .emit_event(question_event("question.rejected", &event_value)); + } + } +} + +async fn apply_item_event( + state: Arc<OpenCodeAppState>, + event: UniversalEvent, + item: UniversalItem, +) { + if matches!(item.kind, ItemKind::ToolCall | ItemKind::ToolResult) { + apply_tool_item_event(state, event, item).await; + return; + } + if item.kind != ItemKind::Message { + return; + } + if matches!(item.role, Some(ItemRole::User)) { + return; + } + let session_id = event.session_id.clone(); + let item_id_key = if item.item_id.is_empty() { + None + } else { + Some(item.item_id.clone()) + }; + let native_id_key = item.native_item_id.clone(); + let mut message_id: Option<String> = None; + let mut parent_id: Option<String> = None; + let runtime = state + .opencode + .update_runtime(&session_id, |runtime| { + parent_id = item + .parent_id + .as_ref() + .and_then(|parent| runtime.message_id_for_item.get(parent).cloned()) + .or_else(|| runtime.last_user_message_id.clone()); + if let Some(existing) = item_id_key + .clone() + .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) + .or_else(|| { + native_id_key + .clone() + .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) + }) + { + message_id = Some(existing); + } else { + let new_id = + unique_assistant_message_id(runtime, parent_id.as_ref(), event.sequence); + message_id = Some(new_id); + } + if let Some(id) = message_id.clone() { + if let Some(item_key) = item_id_key.clone() { + runtime + .message_id_for_item + .insert(item_key, id.clone()); + } + if let Some(native_key) = native_id_key.clone() { + runtime + .message_id_for_item + .insert(native_key, id.clone()); + } + } + }) + .await; + let message_id = message_id + .unwrap_or_else(|| unique_assistant_message_id(&runtime, parent_id.as_ref(), event.sequence)); + let parent_id = parent_id.or_else(|| runtime.last_user_message_id.clone()); + let agent = runtime + .last_agent + .clone() + .unwrap_or_else(|| default_agent_mode().to_string()); + let provider_id = runtime + .last_model_provider + .clone() + .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); + let model_id = runtime + .last_model_id + .clone() + .unwrap_or_else(|| OPENCODE_DEFAULT_MODEL_ID.to_string()); + let directory = session_directory(&state.opencode, &session_id).await; + let worktree = state.opencode.worktree_for(&directory); + let now = state.opencode.now_ms(); + + let mut info = build_assistant_message( + &session_id, + &message_id, + parent_id.as_deref().unwrap_or(""), + now, + &directory, + &worktree, + &agent, + &provider_id, + &model_id, + ); + if event.event_type == UniversalEventType::ItemCompleted { + if let Some(obj) = info.as_object_mut() { + if let Some(time) = obj.get_mut("time").and_then(|v| v.as_object_mut()) { + time.insert("completed".to_string(), json!(now)); + } + } + } + upsert_message_info(&state.opencode, &session_id, info.clone()).await; + state + .opencode + .emit_event(message_event("message.updated", &info)); + + let mut runtime = state + .opencode + .update_runtime(&session_id, |runtime| { + if runtime.last_user_message_id.is_none() { + runtime.last_user_message_id = parent_id.clone(); + } + }) + .await; + + if let Some(text) = extract_text_from_content(&item.content) { + let part_id = runtime + .part_id_by_message + .entry(message_id.clone()) + .or_insert_with(|| format!("{}_text", message_id)) + .clone(); + runtime.text_by_message.insert(message_id.clone(), text.clone()); + let part = build_text_part_with_id(&session_id, &message_id, &part_id, &text); + upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; + state + .opencode + .emit_event(part_event("message.part.updated", &part)); + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + runtime + .text_by_message + .insert(message_id.clone(), text.clone()); + runtime + .part_id_by_message + .insert(message_id.clone(), part_id.clone()); + }) + .await; + } + + for part in item.content.iter() { + match part { + ContentPart::Reasoning { text, .. } => { + let part_id = next_id("part_", &PART_COUNTER); + let reasoning_part = + build_reasoning_part(&session_id, &message_id, &part_id, text, now); + upsert_message_part(&state.opencode, &session_id, &message_id, reasoning_part.clone()) + .await; + state + .opencode + .emit_event(part_event("message.part.updated", &reasoning_part)); + } + ContentPart::ToolCall { + name, + arguments, + call_id, + } => { + let part_id = runtime + .tool_part_by_call + .entry(call_id.clone()) + .or_insert_with(|| next_id("part_", &PART_COUNTER)) + .clone(); + let state_value = json!({ + "status": "pending", + "input": {"arguments": arguments}, + "raw": arguments, + }); + let tool_part = + build_tool_part(&session_id, &message_id, &part_id, call_id, name, state_value); + upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()) + .await; + state + .opencode + .emit_event(part_event("message.part.updated", &tool_part)); + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + runtime + .tool_part_by_call + .insert(call_id.clone(), part_id.clone()); + runtime + .tool_message_by_call + .insert(call_id.clone(), message_id.clone()); + }) + .await; + } + ContentPart::ToolResult { call_id, output } => { + let part_id = runtime + .tool_part_by_call + .entry(call_id.clone()) + .or_insert_with(|| next_id("part_", &PART_COUNTER)) + .clone(); + let state_value = json!({ + "status": "completed", + "input": {}, + "output": output, + "title": "Tool result", + "metadata": {}, + "time": {"start": now, "end": now}, + "attachments": [], + }); + let tool_part = build_tool_part( + &session_id, + &message_id, + &part_id, + call_id, + "tool", + state_value, + ); + upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()) + .await; + state + .opencode + .emit_event(part_event("message.part.updated", &tool_part)); + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + runtime + .tool_part_by_call + .insert(call_id.clone(), part_id.clone()); + runtime + .tool_message_by_call + .insert(call_id.clone(), message_id.clone()); + }) + .await; + } + ContentPart::FileRef { path, action, diff } => { + let mime = match action { + FileAction::Patch => "text/x-diff", + _ => "text/plain", + }; + let part = + build_file_part_from_path(&session_id, &message_id, path, mime, diff.as_deref()); + upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; + state + .opencode + .emit_event(part_event("message.part.updated", &part)); + if matches!(action, FileAction::Write | FileAction::Patch) { + emit_file_edited(&state.opencode, path); + } + } + ContentPart::Image { path, mime } => { + let mime = mime.as_deref().unwrap_or("image/png"); + let part = build_file_part_from_path(&session_id, &message_id, path, mime, None); + upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; + state + .opencode + .emit_event(part_event("message.part.updated", &part)); + } + _ => {} + } + } + + if event.event_type == UniversalEventType::ItemCompleted { + state.opencode.emit_event(json!({ + "type": "session.status", + "properties": { + "sessionID": session_id, + "status": {"type": "idle"} + } + })); + state.opencode.emit_event(json!({ + "type": "session.idle", + "properties": { "sessionID": session_id } + })); + } +} + +async fn apply_tool_item_event( + state: Arc<OpenCodeAppState>, + event: UniversalEvent, + item: UniversalItem, +) { + let session_id = event.session_id.clone(); + let tool_info = extract_tool_content(&item.content); + let call_id = match tool_info.call_id.clone() { + Some(call_id) => call_id, + None => return, + }; + + let item_id_key = if item.item_id.is_empty() { + None + } else { + Some(item.item_id.clone()) + }; + let native_id_key = item.native_item_id.clone(); + let mut message_id: Option<String> = None; + let mut parent_id: Option<String> = None; + let runtime = state + .opencode + .update_runtime(&session_id, |runtime| { + parent_id = item + .parent_id + .as_ref() + .and_then(|parent| runtime.message_id_for_item.get(parent).cloned()) + .or_else(|| runtime.last_user_message_id.clone()); + if let Some(existing) = item_id_key + .clone() + .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) + .or_else(|| { + native_id_key + .clone() + .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) + }) + .or_else(|| runtime.tool_message_by_call.get(&call_id).cloned()) + { + message_id = Some(existing); + } else { + let new_id = + unique_assistant_message_id(runtime, parent_id.as_ref(), event.sequence); + message_id = Some(new_id); + } + if let Some(id) = message_id.clone() { + if let Some(item_key) = item_id_key.clone() { + runtime + .message_id_for_item + .insert(item_key, id.clone()); + } + if let Some(native_key) = native_id_key.clone() { + runtime + .message_id_for_item + .insert(native_key, id.clone()); + } + runtime + .tool_message_by_call + .insert(call_id.clone(), id.clone()); + } + }) + .await; + + let message_id = message_id + .unwrap_or_else(|| unique_assistant_message_id(&runtime, parent_id.as_ref(), event.sequence)); + let parent_id = parent_id.or_else(|| runtime.last_user_message_id.clone()); + let agent = runtime + .last_agent + .clone() + .unwrap_or_else(|| default_agent_mode().to_string()); + let provider_id = runtime + .last_model_provider + .clone() + .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); + let model_id = runtime + .last_model_id + .clone() + .unwrap_or_else(|| OPENCODE_DEFAULT_MODEL_ID.to_string()); + let directory = session_directory(&state.opencode, &session_id).await; + let worktree = state.opencode.worktree_for(&directory); + let now = state.opencode.now_ms(); + + let mut info = build_assistant_message( + &session_id, + &message_id, + parent_id.as_deref().unwrap_or(""), + now, + &directory, + &worktree, + &agent, + &provider_id, + &model_id, + ); + if event.event_type == UniversalEventType::ItemCompleted { + if let Some(obj) = info.as_object_mut() { + if let Some(time) = obj.get_mut("time").and_then(|v| v.as_object_mut()) { + time.insert("completed".to_string(), json!(now)); + } + } + } + upsert_message_info(&state.opencode, &session_id, info.clone()).await; + state + .opencode + .emit_event(message_event("message.updated", &info)); + + let mut attachments = Vec::new(); + if item.kind == ItemKind::ToolResult && event.event_type == UniversalEventType::ItemCompleted { + for (path, action, diff) in tool_info.file_refs.iter() { + let mime = match action { + FileAction::Patch => "text/x-diff", + _ => "text/plain", + }; + let part = + build_file_part_from_path(&session_id, &message_id, path, mime, diff.as_deref()); + upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; + state + .opencode + .emit_event(part_event("message.part.updated", &part)); + attachments.push(part.clone()); + if matches!(action, FileAction::Write | FileAction::Patch) { + emit_file_edited(&state.opencode, path); + } + } + } + + let part_id = runtime + .tool_part_by_call + .get(&call_id) + .cloned() + .unwrap_or_else(|| next_id("part_", &PART_COUNTER)); + let tool_name = tool_info + .tool_name + .clone() + .unwrap_or_else(|| "tool".to_string()); + let input_value = tool_input_from_arguments(tool_info.arguments.as_deref()); + let raw_args = tool_info.arguments.clone().unwrap_or_default(); + let output_value = tool_info + .output + .clone() + .or_else(|| extract_text_from_content(&item.content)); + + let state_value = match event.event_type { + UniversalEventType::ItemStarted => { + if item.kind == ItemKind::ToolResult { + json!({ + "status": "running", + "input": input_value, + "time": {"start": now} + }) + } else { + json!({ + "status": "pending", + "input": input_value, + "raw": raw_args, + }) + } + } + UniversalEventType::ItemCompleted => { + if item.kind == ItemKind::ToolResult { + if matches!(item.status, ItemStatus::Failed) { + json!({ + "status": "error", + "input": input_value, + "error": output_value.unwrap_or_else(|| "Tool failed".to_string()), + "metadata": {}, + "time": {"start": now, "end": now}, + }) + } else { + json!({ + "status": "completed", + "input": input_value, + "output": output_value.unwrap_or_default(), + "title": "Tool result", + "metadata": {}, + "time": {"start": now, "end": now}, + "attachments": attachments, + }) + } + } else { + json!({ + "status": "running", + "input": input_value, + "time": {"start": now}, + }) + } + } + _ => json!({ + "status": "pending", + "input": input_value, + "raw": raw_args, + }), + }; + + let tool_part = build_tool_part( + &session_id, + &message_id, + &part_id, + &call_id, + &tool_name, + state_value, + ); + upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()).await; + state + .opencode + .emit_event(part_event("message.part.updated", &tool_part)); + + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + runtime + .tool_part_by_call + .insert(call_id.clone(), part_id.clone()); + runtime + .tool_message_by_call + .insert(call_id.clone(), message_id.clone()); + }) + .await; +} + +async fn apply_item_delta( + state: Arc<OpenCodeAppState>, + event: UniversalEvent, + item_id: String, + native_item_id: Option<String>, + delta: String, +) { + let session_id = event.session_id.clone(); + let item_id_key = if item_id.is_empty() { None } else { Some(item_id) }; + let native_id_key = native_item_id; + let is_user_delta = item_id_key + .as_ref() + .map(|value| value.starts_with("user_")) + .unwrap_or(false) + || native_id_key + .as_ref() + .map(|value| value.starts_with("user_")) + .unwrap_or(false); + if is_user_delta { + return; + } + let mut message_id: Option<String> = None; + let mut parent_id: Option<String> = None; + let runtime = state + .opencode + .update_runtime(&session_id, |runtime| { + parent_id = runtime.last_user_message_id.clone(); + if let Some(existing) = item_id_key + .clone() + .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) + .or_else(|| { + native_id_key + .clone() + .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) + }) + { + message_id = Some(existing); + } else { + let new_id = + unique_assistant_message_id(runtime, parent_id.as_ref(), event.sequence); + message_id = Some(new_id); + } + if let Some(id) = message_id.clone() { + if let Some(item_key) = item_id_key.clone() { + runtime + .message_id_for_item + .insert(item_key, id.clone()); + } + if let Some(native_key) = native_id_key.clone() { + runtime + .message_id_for_item + .insert(native_key, id.clone()); + } + } + }) + .await; + let message_id = message_id + .unwrap_or_else(|| unique_assistant_message_id(&runtime, parent_id.as_ref(), event.sequence)); + let parent_id = parent_id.or_else(|| runtime.last_user_message_id.clone()); + let directory = session_directory(&state.opencode, &session_id).await; + let worktree = state.opencode.worktree_for(&directory); + let now = state.opencode.now_ms(); + let agent = runtime + .last_agent + .clone() + .unwrap_or_else(|| default_agent_mode().to_string()); + let provider_id = runtime + .last_model_provider + .clone() + .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); + let model_id = runtime + .last_model_id + .clone() + .unwrap_or_else(|| OPENCODE_DEFAULT_MODEL_ID.to_string()); + let info = build_assistant_message( + &session_id, + &message_id, + parent_id.as_deref().unwrap_or(""), + now, + &directory, + &worktree, + &agent, + &provider_id, + &model_id, + ); + upsert_message_info(&state.opencode, &session_id, info.clone()).await; + state + .opencode + .emit_event(message_event("message.updated", &info)); + let mut text = runtime + .text_by_message + .get(&message_id) + .cloned() + .unwrap_or_default(); + text.push_str(&delta); + let part_id = runtime + .part_id_by_message + .get(&message_id) + .cloned() + .unwrap_or_else(|| format!("{}_text", message_id)); + let part = build_text_part_with_id(&session_id, &message_id, &part_id, &text); + upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; + state + .opencode + .emit_event(part_event("message.part.updated", &part)); + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + runtime.text_by_message.insert(message_id.clone(), text); + runtime + .part_id_by_message + .insert(message_id.clone(), part_id.clone()); + }) + .await; +} + +/// Build OpenCode-compatible router. +pub fn build_opencode_router(state: Arc<OpenCodeAppState>) -> Router { + Router::new() + // Core metadata + .route("/agent", get(oc_agent_list)) + .route("/command", get(oc_command_list)) + .route("/config", get(oc_config_get).patch(oc_config_patch)) + .route("/config/providers", get(oc_config_providers)) + .route("/event", get(oc_event_subscribe)) + .route("/global/event", get(oc_global_event)) + .route("/global/health", get(oc_global_health)) + .route("/global/config", get(oc_global_config_get).patch(oc_global_config_patch)) + .route("/global/dispose", post(oc_global_dispose)) + .route("/instance/dispose", post(oc_instance_dispose)) + .route("/log", post(oc_log)) + .route("/lsp", get(oc_lsp_status)) + .route("/formatter", get(oc_formatter_status)) + .route("/path", get(oc_path)) + .route("/vcs", get(oc_vcs)) + .route("/project", get(oc_project_list)) + .route("/project/current", get(oc_project_current)) + .route("/project/:projectID", patch(oc_project_update)) + // Sessions + .route("/session", post(oc_session_create).get(oc_session_list)) + .route("/session/status", get(oc_session_status)) + .route( + "/session/:sessionID", + get(oc_session_get) + .patch(oc_session_update) + .delete(oc_session_delete), + ) + .route("/session/:sessionID/abort", post(oc_session_abort)) + .route("/session/:sessionID/children", get(oc_session_children)) + .route("/session/:sessionID/init", post(oc_session_init)) + .route("/session/:sessionID/fork", post(oc_session_fork)) + .route("/session/:sessionID/diff", get(oc_session_diff)) + .route("/session/:sessionID/summarize", post(oc_session_summarize)) + .route( + "/session/:sessionID/message", + post(oc_session_message_create).get(oc_session_messages), + ) + .route( + "/session/:sessionID/message/:messageID", + get(oc_session_message_get), + ) + .route( + "/session/:sessionID/message/:messageID/part/:partID", + patch(oc_message_part_update).delete(oc_message_part_delete), + ) + .route("/session/:sessionID/prompt_async", post(oc_session_prompt_async)) + .route("/session/:sessionID/command", post(oc_session_command)) + .route("/session/:sessionID/shell", post(oc_session_shell)) + .route("/session/:sessionID/revert", post(oc_session_revert)) + .route("/session/:sessionID/unrevert", post(oc_session_unrevert)) + .route( + "/session/:sessionID/permissions/:permissionID", + post(oc_session_permission_reply), + ) + .route("/session/:sessionID/share", post(oc_session_share).delete(oc_session_unshare)) + .route("/session/:sessionID/todo", get(oc_session_todo)) + // Permissions + questions (global) + .route("/permission", get(oc_permission_list)) + .route("/permission/:requestID/reply", post(oc_permission_reply)) + .route("/question", get(oc_question_list)) + .route("/question/:requestID/reply", post(oc_question_reply)) + .route("/question/:requestID/reject", post(oc_question_reject)) + // Providers + .route("/provider", get(oc_provider_list)) + .route("/provider/auth", get(oc_provider_auth)) + .route( + "/provider/:providerID/oauth/authorize", + post(oc_provider_oauth_authorize), + ) + .route( + "/provider/:providerID/oauth/callback", + post(oc_provider_oauth_callback), + ) + // Auth + .route("/auth/:providerID", put(oc_auth_set).delete(oc_auth_remove)) + // PTY + .route("/pty", get(oc_pty_list).post(oc_pty_create)) + .route( + "/pty/:ptyID", + get(oc_pty_get).put(oc_pty_update).delete(oc_pty_delete), + ) + .route("/pty/:ptyID/connect", get(oc_pty_connect)) + // Files + .route("/file", get(oc_file_list)) + .route("/file/content", get(oc_file_content)) + .route("/file/status", get(oc_file_status)) + // Find + .route("/find", get(oc_find_text)) + .route("/find/file", get(oc_find_files)) + .route("/find/symbol", get(oc_find_symbols)) + // MCP + .route("/mcp", get(oc_mcp_list).post(oc_mcp_register)) + .route("/mcp/:name/auth", post(oc_mcp_auth).delete(oc_mcp_auth_remove)) + .route("/mcp/:name/auth/callback", post(oc_mcp_auth_callback)) + .route("/mcp/:name/auth/authenticate", post(oc_mcp_authenticate)) + .route("/mcp/:name/connect", post(oc_mcp_connect)) + .route("/mcp/:name/disconnect", post(oc_mcp_disconnect)) + // Experimental + .route("/experimental/tool/ids", get(oc_tool_ids)) + .route("/experimental/tool", get(oc_tool_list)) + .route("/experimental/resource", get(oc_resource_list)) + .route( + "/experimental/worktree", + get(oc_worktree_list).post(oc_worktree_create).delete(oc_worktree_delete), + ) + .route("/experimental/worktree/reset", post(oc_worktree_reset)) + // Skills + .route("/skill", get(oc_skill_list)) + // TUI + .route("/tui/control/next", get(oc_tui_next)) + .route("/tui/control/response", post(oc_tui_response)) + .route("/tui/append-prompt", post(oc_tui_append_prompt)) + .route("/tui/open-help", post(oc_tui_open_help)) + .route("/tui/open-sessions", post(oc_tui_open_sessions)) + .route("/tui/open-themes", post(oc_tui_open_themes)) + .route("/tui/open-models", post(oc_tui_open_models)) + .route("/tui/submit-prompt", post(oc_tui_submit_prompt)) + .route("/tui/clear-prompt", post(oc_tui_clear_prompt)) + .route("/tui/execute-command", post(oc_tui_execute_command)) + .route("/tui/show-toast", post(oc_tui_show_toast)) + .route("/tui/publish", post(oc_tui_publish)) + .route("/tui/select-session", post(oc_tui_select_session)) + .with_state(state) +} + +// =================================================================================== +// Handler implementations +// =================================================================================== + +#[utoipa::path( + get, + path = "/agent", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_agent_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { + let agent = json!({ + "name": OPENCODE_PROVIDER_NAME, + "description": "Sandbox Agent compatibility layer", + "mode": "all", + "native": false, + "hidden": false, + "permission": [], + "options": {}, + }); + (StatusCode::OK, Json(json!([agent]))) +} + +#[utoipa::path( + get, + path = "/command", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_command_list() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + get, + path = "/config", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_config_get() -> impl IntoResponse { + (StatusCode::OK, Json(json!({}))) +} + +#[utoipa::path( + patch, + path = "/config", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_config_patch(Json(body): Json<Value>) -> impl IntoResponse { + (StatusCode::OK, Json(body)) +} + +#[utoipa::path( + get, + path = "/config/providers", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_config_providers() -> impl IntoResponse { + let mut models = serde_json::Map::new(); + for agent in available_agent_ids() { + models.insert(agent.as_str().to_string(), model_config_entry(agent)); + } + let providers = json!({ + "providers": [ + { + "id": OPENCODE_PROVIDER_ID, + "name": OPENCODE_PROVIDER_NAME, + "source": "custom", + "env": [], + "key": "", + "options": {}, + "models": Value::Object(models), + } + ], + "default": { + OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID + } + }); + (StatusCode::OK, Json(providers)) +} + +#[utoipa::path( + get, + path = "/event", + responses((status = 200, description = "SSE event stream")), + tag = "opencode" +)] +async fn oc_event_subscribe( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, +) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> { + let receiver = state.opencode.subscribe(); + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let branch = state.opencode.branch_name(); + state.opencode.emit_event(json!({ + "type": "server.connected", + "properties": {} + })); + state.opencode.emit_event(json!({ + "type": "worktree.ready", + "properties": { + "name": directory, + "branch": branch, + } + })); + + let heartbeat_payload = json!({ + "type": "server.heartbeat", + "properties": {} + }); + let stream = stream::unfold((receiver, interval(std::time::Duration::from_secs(30))), move |(mut rx, mut ticker)| { + let heartbeat = heartbeat_payload.clone(); + async move { + tokio::select! { + _ = ticker.tick() => { + let sse_event = Event::default() + .json_data(&heartbeat) + .unwrap_or_else(|_| Event::default().data("{}")); + Some((Ok(sse_event), (rx, ticker))) + } + event = rx.recv() => { + match event { + Ok(event) => { + let sse_event = Event::default() + .json_data(&event) + .unwrap_or_else(|_| Event::default().data("{}")); + Some((Ok(sse_event), (rx, ticker))) + } + Err(broadcast::error::RecvError::Lagged(_)) => { + Some((Ok(Event::default().comment("lagged")), (rx, ticker))) + } + Err(broadcast::error::RecvError::Closed) => None, + } + } + } + } + }); + + Sse::new(stream).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15))) +} + +#[utoipa::path( + get, + path = "/global/event", + responses((status = 200, description = "SSE event stream")), + tag = "opencode" +)] +async fn oc_global_event( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, +) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> { + let receiver = state.opencode.subscribe(); + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let branch = state.opencode.branch_name(); + state.opencode.emit_event(json!({ + "type": "server.connected", + "properties": {} + })); + state.opencode.emit_event(json!({ + "type": "worktree.ready", + "properties": { + "name": directory.clone(), + "branch": branch, + } + })); + + let heartbeat_payload = json!({ + "payload": { + "type": "server.heartbeat", + "properties": {} + } + }); + let stream = stream::unfold((receiver, interval(std::time::Duration::from_secs(30))), move |(mut rx, mut ticker)| { + let directory = directory.clone(); + let heartbeat = heartbeat_payload.clone(); + async move { + tokio::select! { + _ = ticker.tick() => { + let sse_event = Event::default() + .json_data(&heartbeat) + .unwrap_or_else(|_| Event::default().data("{}")); + Some((Ok(sse_event), (rx, ticker))) + } + event = rx.recv() => { + match event { + Ok(event) => { + let payload = json!({"directory": directory, "payload": event}); + let sse_event = Event::default() + .json_data(&payload) + .unwrap_or_else(|_| Event::default().data("{}")); + Some((Ok(sse_event), (rx, ticker))) + } + Err(broadcast::error::RecvError::Lagged(_)) => { + Some((Ok(Event::default().comment("lagged")), (rx, ticker))) + } + Err(broadcast::error::RecvError::Closed) => None, + } + } + } + } + }); + + Sse::new(stream).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15))) +} + +#[utoipa::path( + get, + path = "/global/health", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_global_health() -> impl IntoResponse { + ( + StatusCode::OK, + Json(json!({ + "healthy": true, + "version": env!("CARGO_PKG_VERSION"), + })), + ) +} + +#[utoipa::path( + get, + path = "/global/config", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_global_config_get() -> impl IntoResponse { + (StatusCode::OK, Json(json!({}))) +} + +#[utoipa::path( + patch, + path = "/global/config", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_global_config_patch(Json(body): Json<Value>) -> impl IntoResponse { + (StatusCode::OK, Json(body)) +} + +#[utoipa::path( + post, + path = "/global/dispose", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_global_dispose() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/instance/dispose", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_instance_dispose() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/log", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_log() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + get, + path = "/lsp", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_lsp_status() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + get, + path = "/formatter", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_formatter_status() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + get, + path = "/path", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_path(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, None); + let worktree = state.opencode.worktree_for(&directory); + ( + StatusCode::OK, + Json(json!({ + "home": state.opencode.home_dir(), + "state": state.opencode.state_dir(), + "config": state.opencode.config_dir(), + "worktree": worktree, + "directory": directory, + })), + ) +} + +#[utoipa::path( + get, + path = "/vcs", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_vcs(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { + ( + StatusCode::OK, + Json(json!({ + "branch": state.opencode.branch_name(), + })), + ) +} + +#[utoipa::path( + get, + path = "/project", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_project_list(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, None); + let worktree = state.opencode.worktree_for(&directory); + let now = state.opencode.now_ms(); + let project = json!({ + "id": state.opencode.default_project_id.clone(), + "worktree": worktree, + "vcs": "git", + "name": "sandbox-agent", + "time": {"created": now, "updated": now}, + "sandboxes": [], + }); + (StatusCode::OK, Json(json!([project]))) +} + +#[utoipa::path( + get, + path = "/project/current", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_project_current(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, None); + let worktree = state.opencode.worktree_for(&directory); + let now = state.opencode.now_ms(); + ( + StatusCode::OK, + Json(json!({ + "id": state.opencode.default_project_id.clone(), + "worktree": worktree, + "vcs": "git", + "name": "sandbox-agent", + "time": {"created": now, "updated": now}, + "sandboxes": [], + })), + ) +} + +#[utoipa::path( + patch, + path = "/project/{projectID}", + params(("projectID" = String, Path, description = "Project ID")), + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_project_update( + State(state): State<Arc<OpenCodeAppState>>, + Path(_project_id): Path<String>, + headers: HeaderMap, +) -> impl IntoResponse { + oc_project_current(State(state), headers).await +} + +#[utoipa::path( + post, + path = "/session", + request_body = OpenCodeCreateSessionRequest, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_create( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, + body: Option<Json<OpenCodeCreateSessionRequest>>, +) -> impl IntoResponse { + let body = body.map(|j| j.0).unwrap_or(OpenCodeCreateSessionRequest { + title: None, + parent_id: None, + permission: None, + }); + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let now = state.opencode.now_ms(); + let id = next_id("ses_", &SESSION_COUNTER); + let slug = format!("session-{}", id); + let title = body.title.unwrap_or_else(|| format!("Session {}", id)); + let record = OpenCodeSessionRecord { + id: id.clone(), + slug, + project_id: state.opencode.default_project_id.clone(), + directory, + parent_id: body.parent_id, + title, + version: "0".to_string(), + created_at: now, + updated_at: now, + share_url: None, + }; + + let session_value = record.to_value(); + + let mut sessions = state.opencode.sessions.lock().await; + sessions.insert(id.clone(), record); + drop(sessions); + + state + .opencode + .emit_event(session_event("session.created", &session_value)); + + (StatusCode::OK, Json(session_value)) +} + +#[utoipa::path( + get, + path = "/session", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { + let sessions = state.opencode.sessions.lock().await; + let values: Vec<Value> = sessions.values().map(|s| s.to_value()).collect(); + (StatusCode::OK, Json(json!(values))) +} + +#[utoipa::path( + get, + path = "/session/{sessionID}", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_session_get( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + _headers: HeaderMap, + _query: Query<DirectoryQuery>, +) -> impl IntoResponse { + let sessions = state.opencode.sessions.lock().await; + if let Some(session) = sessions.get(&session_id) { + return (StatusCode::OK, Json(session.to_value())).into_response(); + } + not_found("Session not found").into_response() +} + +#[utoipa::path( + patch, + path = "/session/{sessionID}", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = OpenCodeUpdateSessionRequest, + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_session_update( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + Json(body): Json<OpenCodeUpdateSessionRequest>, +) -> impl IntoResponse { + let mut sessions = state.opencode.sessions.lock().await; + if let Some(session) = sessions.get_mut(&session_id) { + if let Some(title) = body.title { + session.title = title; + session.updated_at = state.opencode.now_ms(); + } + let value = session.to_value(); + state + .opencode + .emit_event(session_event("session.updated", &value)); + return (StatusCode::OK, Json(value)).into_response(); + } + not_found("Session not found").into_response() +} + +#[utoipa::path( + delete, + path = "/session/{sessionID}", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_session_delete( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, +) -> impl IntoResponse { + let mut sessions = state.opencode.sessions.lock().await; + if let Some(session) = sessions.remove(&session_id) { + state + .opencode + .emit_event(session_event("session.deleted", &session.to_value())); + return bool_ok(true).into_response(); + } + not_found("Session not found").into_response() +} + +#[utoipa::path( + get, + path = "/session/status", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_status(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { + let sessions = state.opencode.sessions.lock().await; + let mut status_map = serde_json::Map::new(); + for id in sessions.keys() { + status_map.insert(id.clone(), json!({"type": "idle"})); + } + (StatusCode::OK, Json(Value::Object(status_map))) +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/abort", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_abort( + State(_state): State<Arc<OpenCodeAppState>>, + Path(_session_id): Path<String>, +) -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + get, + path = "/session/{sessionID}/children", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_children() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/init", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_init() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/fork", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_fork( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let now = state.opencode.now_ms(); + let id = next_id("ses_", &SESSION_COUNTER); + let slug = format!("session-{}", id); + let title = format!("Fork of {}", session_id); + let record = OpenCodeSessionRecord { + id: id.clone(), + slug, + project_id: state.opencode.default_project_id.clone(), + directory, + parent_id: Some(session_id), + title, + version: "0".to_string(), + created_at: now, + updated_at: now, + share_url: None, + }; + + let value = record.to_value(); + let mut sessions = state.opencode.sessions.lock().await; + sessions.insert(id.clone(), record); + drop(sessions); + + state + .opencode + .emit_event(session_event("session.created", &value)); + + (StatusCode::OK, Json(value)) +} + +#[utoipa::path( + get, + path = "/session/{sessionID}/diff", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_diff() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/summarize", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = SessionSummarizeRequest, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_summarize( + Json(body): Json<SessionSummarizeRequest>, +) -> impl IntoResponse { + if body.provider_id.is_none() || body.model_id.is_none() { + return bad_request("providerID and modelID are required"); + } + bool_ok(true) +} + +#[utoipa::path( + get, + path = "/session/{sessionID}/message", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_messages( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, +) -> impl IntoResponse { + let messages = state.opencode.messages.lock().await; + let entries = messages.get(&session_id).cloned().unwrap_or_default(); + let values: Vec<Value> = entries + .into_iter() + .map(|record| json!({"info": record.info, "parts": record.parts})) + .collect(); + (StatusCode::OK, Json(json!(values))) +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/message", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = SessionMessageRequest, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_message_create( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, + Json(body): Json<SessionMessageRequest>, +) -> impl IntoResponse { + if std::env::var("OPENCODE_COMPAT_LOG_BODY").is_ok() { + tracing::info!(target = "sandbox_agent::opencode", ?body, "opencode prompt body"); + } + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let _ = state + .opencode + .ensure_session(&session_id, directory.clone()) + .await; + let worktree = state.opencode.worktree_for(&directory); + let agent_mode = normalize_agent_mode(body.agent.clone()); + let requested_provider = body + .model + .as_ref() + .and_then(|v| v.get("providerID")) + .and_then(|v| v.as_str()); + let requested_model = body + .model + .as_ref() + .and_then(|v| v.get("modelID")) + .and_then(|v| v.as_str()); + let (session_agent, provider_id, model_id) = + resolve_session_agent(&state, &session_id, requested_provider, requested_model).await; + + let parts_input = body.parts.unwrap_or_default(); + if parts_input.is_empty() { + return bad_request("parts are required").into_response(); + } + + let now = state.opencode.now_ms(); + let user_message_id = body + .message_id + .clone() + .unwrap_or_else(|| next_id("msg_", &MESSAGE_COUNTER)); + + state.opencode.emit_event(json!({ + "type": "session.status", + "properties": { + "sessionID": session_id, + "status": {"type": "busy"} + } + })); + + let mut user_message = build_user_message( + &session_id, + &user_message_id, + now, + &agent_mode, + &provider_id, + &model_id, + ); + if let Some(obj) = user_message.as_object_mut() { + if let Some(time) = obj.get_mut("time").and_then(|v| v.as_object_mut()) { + time.insert("completed".to_string(), json!(now)); + } + } + + let parts: Vec<Value> = parts_input + .iter() + .map(|part| normalize_part(&session_id, &user_message_id, part)) + .collect(); + + upsert_message_info(&state.opencode, &session_id, user_message.clone()).await; + for part in &parts { + upsert_message_part(&state.opencode, &session_id, &user_message_id, part.clone()).await; + } + + state + .opencode + .emit_event(message_event("message.updated", &user_message)); + for part in &parts { + state + .opencode + .emit_event(part_event("message.part.updated", part)); + } + + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + runtime.last_user_message_id = Some(user_message_id.clone()); + runtime.last_agent = Some(agent_mode.clone()); + runtime.last_model_provider = Some(provider_id.clone()); + runtime.last_model_id = Some(model_id.clone()); + }) + .await; + + if let Err(err) = ensure_backing_session(&state, &session_id, &session_agent).await { + tracing::warn!( + target = "sandbox_agent::opencode", + ?err, + "failed to ensure backing session" + ); + } else { + ensure_session_stream(state.clone(), session_id.clone()).await; + } + + let prompt_text = parts_input + .iter() + .find_map(|part| part.get("text").and_then(|v| v.as_str())) + .unwrap_or("") + .to_string(); + if !prompt_text.is_empty() { + if let Err(err) = state + .inner + .session_manager() + .send_message(session_id.clone(), prompt_text) + .await + { + tracing::warn!( + target = "sandbox_agent::opencode", + ?err, + "failed to send message to backing agent" + ); + } + } + + let assistant_message = build_assistant_message( + &session_id, + &format!("{user_message_id}_pending"), + &user_message_id, + now, + &directory, + &worktree, + &agent_mode, + &provider_id, + &model_id, + ); + + ( + StatusCode::OK, + Json(json!({ + "info": assistant_message, + "parts": [], + })), + ) + .into_response() +} + +#[utoipa::path( + get, + path = "/session/{sessionID}/message/{messageID}", + params( + ("sessionID" = String, Path, description = "Session ID"), + ("messageID" = String, Path, description = "Message ID") + ), + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_session_message_get( + State(state): State<Arc<OpenCodeAppState>>, + Path((session_id, message_id)): Path<(String, String)>, +) -> impl IntoResponse { + let messages = state.opencode.messages.lock().await; + if let Some(entries) = messages.get(&session_id) { + if let Some(record) = entries.iter().find(|record| { + record + .info + .get("id") + .and_then(|v| v.as_str()) + .map(|id| id == message_id) + .unwrap_or(false) + }) { + return ( + StatusCode::OK, + Json(json!({ + "info": record.info.clone(), + "parts": record.parts.clone() + })), + ) + .into_response(); + } + } + not_found("Message not found").into_response() +} + +#[utoipa::path( + patch, + path = "/session/{sessionID}/message/{messageID}/part/{partID}", + params( + ("sessionID" = String, Path, description = "Session ID"), + ("messageID" = String, Path, description = "Message ID"), + ("partID" = String, Path, description = "Part ID") + ), + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_message_part_update( + State(state): State<Arc<OpenCodeAppState>>, + Path((session_id, message_id, part_id)): Path<(String, String, String)>, + Json(mut part_value): Json<Value>, +) -> impl IntoResponse { + if let Some(obj) = part_value.as_object_mut() { + obj.insert("id".to_string(), json!(part_id)); + obj.insert("sessionID".to_string(), json!(session_id)); + obj.insert("messageID".to_string(), json!(message_id)); + } + + state + .opencode + .emit_event(part_event("message.part.updated", &part_value)); + + (StatusCode::OK, Json(part_value)) +} + +#[utoipa::path( + delete, + path = "/session/{sessionID}/message/{messageID}/part/{partID}", + params( + ("sessionID" = String, Path, description = "Session ID"), + ("messageID" = String, Path, description = "Message ID"), + ("partID" = String, Path, description = "Part ID") + ), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_message_part_delete( + State(state): State<Arc<OpenCodeAppState>>, + Path((session_id, message_id, part_id)): Path<(String, String, String)>, +) -> impl IntoResponse { + let part_value = json!({ + "id": part_id, + "sessionID": session_id, + "messageID": message_id, + "type": "text", + "text": "", + }); + state + .opencode + .emit_event(part_event("message.part.removed", &part_value)); + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/prompt_async", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = SessionMessageRequest, + responses((status = 204)), + tag = "opencode" +)] +async fn oc_session_prompt_async( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, + Json(body): Json<SessionMessageRequest>, +) -> impl IntoResponse { + let _ = oc_session_message_create( + State(state), + Path(session_id), + headers, + Query(query), + Json(body), + ) + .await; + StatusCode::NO_CONTENT +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/command", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = SessionCommandRequest, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_command( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, + Json(body): Json<SessionCommandRequest>, +) -> impl IntoResponse { + if body.command.is_none() || body.arguments.is_none() { + return bad_request("command and arguments are required").into_response(); + } + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let worktree = state.opencode.worktree_for(&directory); + let now = state.opencode.now_ms(); + let assistant_message_id = next_id("msg_", &MESSAGE_COUNTER); + let agent = normalize_agent_mode(body.agent.clone()); + let assistant_message = build_assistant_message( + &session_id, + &assistant_message_id, + "msg_parent", + now, + &directory, + &worktree, + &agent, + OPENCODE_PROVIDER_ID, + OPENCODE_DEFAULT_MODEL_ID, + ); + + ( + StatusCode::OK, + Json(json!({ + "info": assistant_message, + "parts": [], + })), + ) + .into_response() +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/shell", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = SessionShellRequest, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_shell( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, + Json(body): Json<SessionShellRequest>, +) -> impl IntoResponse { + if body.command.is_none() || body.agent.is_none() { + return bad_request("agent and command are required").into_response(); + } + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let worktree = state.opencode.worktree_for(&directory); + let now = state.opencode.now_ms(); + let assistant_message_id = next_id("msg_", &MESSAGE_COUNTER); + let assistant_message = build_assistant_message( + &session_id, + &assistant_message_id, + "msg_parent", + now, + &directory, + &worktree, + &normalize_agent_mode(body.agent.clone()), + body.model + .as_ref() + .and_then(|v| v.get("providerID")) + .and_then(|v| v.as_str()) + .unwrap_or(OPENCODE_PROVIDER_ID), + body.model + .as_ref() + .and_then(|v| v.get("modelID")) + .and_then(|v| v.as_str()) + .unwrap_or(OPENCODE_DEFAULT_MODEL_ID), + ); + (StatusCode::OK, Json(assistant_message)).into_response() +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/revert", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_revert( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, +) -> impl IntoResponse { + oc_session_get(State(state), Path(session_id), headers, Query(query)).await +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/unrevert", + params(("sessionID" = String, Path, description = "Session ID")), + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_unrevert( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, +) -> impl IntoResponse { + oc_session_get(State(state), Path(session_id), headers, Query(query)).await +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/permissions/{permissionID}", + params( + ("sessionID" = String, Path, description = "Session ID"), + ("permissionID" = String, Path, description = "Permission ID") + ), + request_body = PermissionReplyRequest, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_permission_reply( + State(state): State<Arc<OpenCodeAppState>>, + Path((session_id, permission_id)): Path<(String, String)>, + Json(body): Json<PermissionReplyRequest>, +) -> impl IntoResponse { + let reply = match parse_permission_reply_value(body.response.as_deref()) { + Ok(reply) => reply, + Err(message) => return bad_request(&message).into_response(), + }; + match state + .inner + .session_manager() + .reply_permission(&session_id, &permission_id, reply) + .await + { + Ok(_) => bool_ok(true).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } +} + +#[utoipa::path( + post, + path = "/session/{sessionID}/share", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_share( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, +) -> impl IntoResponse { + let mut sessions = state.opencode.sessions.lock().await; + if let Some(session) = sessions.get_mut(&session_id) { + session.share_url = Some(format!("https://share.local/{}", session_id)); + let value = session.to_value(); + return (StatusCode::OK, Json(value)).into_response(); + } + not_found("Session not found").into_response() +} + +#[utoipa::path( + delete, + path = "/session/{sessionID}/share", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_unshare( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, +) -> impl IntoResponse { + let mut sessions = state.opencode.sessions.lock().await; + if let Some(session) = sessions.get_mut(&session_id) { + session.share_url = None; + let value = session.to_value(); + return (StatusCode::OK, Json(value)).into_response(); + } + not_found("Session not found").into_response() +} + +#[utoipa::path( + get, + path = "/session/{sessionID}/todo", + params(("sessionID" = String, Path, description = "Session ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_session_todo() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + get, + path = "/permission", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_permission_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { + let pending = state.inner.session_manager().list_pending_permissions().await; + let mut values = Vec::new(); + for item in pending { + let record = OpenCodePermissionRecord { + id: item.permission_id, + session_id: item.session_id, + permission: item.action, + patterns: patterns_from_metadata(&item.metadata), + metadata: item.metadata.unwrap_or_else(|| json!({})), + always: Vec::new(), + tool: None, + }; + values.push(record.to_value()); + } + values.sort_by(|a, b| { + let a_id = a.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let b_id = b.get("id").and_then(|v| v.as_str()).unwrap_or(""); + a_id.cmp(b_id) + }); + (StatusCode::OK, Json(json!(values))) +} + +#[utoipa::path( + post, + path = "/permission/{requestID}/reply", + params(("requestID" = String, Path, description = "Permission request ID")), + request_body = PermissionGlobalReplyRequest, + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_permission_reply( + State(state): State<Arc<OpenCodeAppState>>, + Path(request_id): Path<String>, + Json(body): Json<PermissionGlobalReplyRequest>, +) -> impl IntoResponse { + let reply = match parse_permission_reply_value(body.reply.as_deref()) { + Ok(reply) => reply, + Err(message) => return bad_request(&message).into_response(), + }; + let session_id = state + .inner + .session_manager() + .list_pending_permissions() + .await + .into_iter() + .find(|item| item.permission_id == request_id) + .map(|item| item.session_id); + let Some(session_id) = session_id else { + return not_found("Permission request not found").into_response(); + }; + match state + .inner + .session_manager() + .reply_permission(&session_id, &request_id, reply) + .await + { + Ok(_) => bool_ok(true).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } +} + +#[utoipa::path( + get, + path = "/question", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_question_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { + let pending = state.inner.session_manager().list_pending_questions().await; + let mut values = Vec::new(); + for item in pending { + let options: Vec<Value> = item + .options + .iter() + .map(|option| json!({"label": option, "description": ""})) + .collect(); + let record = OpenCodeQuestionRecord { + id: item.question_id, + session_id: item.session_id, + questions: vec![json!({ + "header": "Question", + "question": item.prompt, + "options": options, + })], + tool: None, + }; + values.push(record.to_value()); + } + values.sort_by(|a, b| { + let a_id = a.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let b_id = b.get("id").and_then(|v| v.as_str()).unwrap_or(""); + a_id.cmp(b_id) + }); + (StatusCode::OK, Json(json!(values))) +} + +#[utoipa::path( + post, + path = "/question/{requestID}/reply", + params(("requestID" = String, Path, description = "Question request ID")), + request_body = QuestionReplyBody, + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_question_reply( + State(state): State<Arc<OpenCodeAppState>>, + Path(request_id): Path<String>, + Json(body): Json<QuestionReplyBody>, +) -> impl IntoResponse { + let session_id = state + .inner + .session_manager() + .list_pending_questions() + .await + .into_iter() + .find(|item| item.question_id == request_id) + .map(|item| item.session_id); + let Some(session_id) = session_id else { + return not_found("Question request not found").into_response(); + }; + let answers = body.answers.unwrap_or_default(); + match state + .inner + .session_manager() + .reply_question(&session_id, &request_id, answers) + .await + { + Ok(_) => bool_ok(true).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } +} + +#[utoipa::path( + post, + path = "/question/{requestID}/reject", + params(("requestID" = String, Path, description = "Question request ID")), + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_question_reject( + State(state): State<Arc<OpenCodeAppState>>, + Path(request_id): Path<String>, +) -> impl IntoResponse { + let session_id = state + .inner + .session_manager() + .list_pending_questions() + .await + .into_iter() + .find(|item| item.question_id == request_id) + .map(|item| item.session_id); + let Some(session_id) = session_id else { + return not_found("Question request not found").into_response(); + }; + match state + .inner + .session_manager() + .reject_question(&session_id, &request_id) + .await + { + Ok(_) => bool_ok(true).into_response(), + Err(err) => sandbox_error_response(err).into_response(), + } +} + +#[utoipa::path( + get, + path = "/provider", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_provider_list() -> impl IntoResponse { + let mut models = serde_json::Map::new(); + for agent in available_agent_ids() { + models.insert(agent.as_str().to_string(), model_summary_entry(agent)); + } + let providers = json!({ + "all": [ + { + "id": OPENCODE_PROVIDER_ID, + "name": OPENCODE_PROVIDER_NAME, + "env": [], + "models": Value::Object(models), + } + ], + "default": { + OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID + }, + "connected": [OPENCODE_PROVIDER_ID] + }); + (StatusCode::OK, Json(providers)) +} + +#[utoipa::path( + get, + path = "/provider/auth", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_provider_auth() -> impl IntoResponse { + let auth = json!({ + OPENCODE_PROVIDER_ID: [] + }); + (StatusCode::OK, Json(auth)) +} + + +#[utoipa::path( + post, + path = "/provider/{providerID}/oauth/authorize", + params(("providerID" = String, Path, description = "Provider ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_provider_oauth_authorize(Path(provider_id): Path<String>) -> impl IntoResponse { + ( + StatusCode::OK, + Json(json!({ + "url": format!("https://auth.local/{}/authorize", provider_id), + "method": "auto", + "instructions": "stub", + })), + ) +} + +#[utoipa::path( + post, + path = "/provider/{providerID}/oauth/callback", + params(("providerID" = String, Path, description = "Provider ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_provider_oauth_callback(Path(_provider_id): Path<String>) -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + put, + path = "/auth/{providerID}", + params(("providerID" = String, Path, description = "Provider ID")), + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_auth_set(Path(_provider_id): Path<String>, Json(_body): Json<Value>) -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + delete, + path = "/auth/{providerID}", + params(("providerID" = String, Path, description = "Provider ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_auth_remove(Path(_provider_id): Path<String>) -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + get, + path = "/pty", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_pty_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { + let ptys = state.opencode.ptys.lock().await; + let values: Vec<Value> = ptys.values().map(|p| p.to_value()).collect(); + (StatusCode::OK, Json(json!(values))) +} + +#[utoipa::path( + post, + path = "/pty", + request_body = PtyCreateRequest, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_pty_create( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, + Json(body): Json<PtyCreateRequest>, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let id = next_id("pty_", &PTY_COUNTER); + let record = OpenCodePtyRecord { + id: id.clone(), + title: body.title.unwrap_or_else(|| "PTY".to_string()), + command: body.command.unwrap_or_else(|| "bash".to_string()), + args: body.args.unwrap_or_default(), + cwd: body.cwd.unwrap_or_else(|| directory), + status: "running".to_string(), + pid: 0, + }; + let value = record.to_value(); + let mut ptys = state.opencode.ptys.lock().await; + ptys.insert(id, record); + drop(ptys); + + state + .opencode + .emit_event(json!({"type": "pty.created", "properties": {"pty": value}})); + + (StatusCode::OK, Json(value)) +} + +#[utoipa::path( + get, + path = "/pty/{ptyID}", + params(("ptyID" = String, Path, description = "Pty ID")), + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_pty_get( + State(state): State<Arc<OpenCodeAppState>>, + Path(pty_id): Path<String>, +) -> impl IntoResponse { + let ptys = state.opencode.ptys.lock().await; + if let Some(pty) = ptys.get(&pty_id) { + return (StatusCode::OK, Json(pty.to_value())).into_response(); + } + not_found("PTY not found").into_response() +} + +#[utoipa::path( + put, + path = "/pty/{ptyID}", + params(("ptyID" = String, Path, description = "Pty ID")), + request_body = String, + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_pty_update( + State(state): State<Arc<OpenCodeAppState>>, + Path(pty_id): Path<String>, + Json(body): Json<PtyCreateRequest>, +) -> impl IntoResponse { + let mut ptys = state.opencode.ptys.lock().await; + if let Some(pty) = ptys.get_mut(&pty_id) { + if let Some(title) = body.title { + pty.title = title; + } + if let Some(command) = body.command { + pty.command = command; + } + if let Some(args) = body.args { + pty.args = args; + } + if let Some(cwd) = body.cwd { + pty.cwd = cwd; + } + let value = pty.to_value(); + state + .opencode + .emit_event(json!({"type": "pty.updated", "properties": {"pty": value}})); + return (StatusCode::OK, Json(value)).into_response(); + } + not_found("PTY not found").into_response() +} + +#[utoipa::path( + delete, + path = "/pty/{ptyID}", + params(("ptyID" = String, Path, description = "Pty ID")), + responses((status = 200), (status = 404)), + tag = "opencode" +)] +async fn oc_pty_delete( + State(state): State<Arc<OpenCodeAppState>>, + Path(pty_id): Path<String>, +) -> impl IntoResponse { + let mut ptys = state.opencode.ptys.lock().await; + if let Some(pty) = ptys.remove(&pty_id) { + state + .opencode + .emit_event(json!({"type": "pty.deleted", "properties": {"pty": pty.to_value()}})); + return bool_ok(true).into_response(); + } + not_found("PTY not found").into_response() +} + +#[utoipa::path( + get, + path = "/pty/{ptyID}/connect", + params(("ptyID" = String, Path, description = "Pty ID")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_pty_connect(Path(_pty_id): Path<String>) -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + get, + path = "/file", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_file_list() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + get, + path = "/file/content", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_file_content(Query(query): Query<FileContentQuery>) -> impl IntoResponse { + if query.path.is_none() { + return bad_request("path is required").into_response(); + } + ( + StatusCode::OK, + Json(json!({ + "type": "text", + "content": "", + })), + ) + .into_response() +} + +#[utoipa::path( + get, + path = "/file/status", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_file_status() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))).into_response() +} + +#[utoipa::path( + get, + path = "/find", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_find_text(Query(query): Query<FindTextQuery>) -> impl IntoResponse { + if query.pattern.is_none() { + return bad_request("pattern is required").into_response(); + } + (StatusCode::OK, Json(json!([]))).into_response() +} + +#[utoipa::path( + get, + path = "/find/file", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_find_files(Query(query): Query<FindFilesQuery>) -> impl IntoResponse { + if query.query.is_none() { + return bad_request("query is required").into_response(); + } + (StatusCode::OK, Json(json!([]))).into_response() +} + +#[utoipa::path( + get, + path = "/find/symbol", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_find_symbols(Query(query): Query<FindSymbolsQuery>) -> impl IntoResponse { + if query.query.is_none() { + return bad_request("query is required").into_response(); + } + (StatusCode::OK, Json(json!([]))).into_response() +} + +#[utoipa::path( + get, + path = "/mcp", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_list() -> impl IntoResponse { + (StatusCode::OK, Json(json!({}))) +} + +#[utoipa::path( + post, + path = "/mcp", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_register() -> impl IntoResponse { + (StatusCode::OK, Json(json!({}))) +} + +#[utoipa::path( + post, + path = "/mcp/{name}/auth", + params(("name" = String, Path, description = "MCP server name")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_auth( + Path(_name): Path<String>, + _body: Option<Json<Value>>, +) -> impl IntoResponse { + (StatusCode::OK, Json(json!({"status": "needs_auth"}))) +} + +#[utoipa::path( + delete, + path = "/mcp/{name}/auth", + params(("name" = String, Path, description = "MCP server name")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_auth_remove(Path(_name): Path<String>) -> impl IntoResponse { + (StatusCode::OK, Json(json!({"status": "disabled"}))) +} + +#[utoipa::path( + post, + path = "/mcp/{name}/auth/callback", + params(("name" = String, Path, description = "MCP server name")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_auth_callback( + Path(_name): Path<String>, + _body: Option<Json<Value>>, +) -> impl IntoResponse { + (StatusCode::OK, Json(json!({"status": "needs_auth"}))) +} + +#[utoipa::path( + post, + path = "/mcp/{name}/auth/authenticate", + params(("name" = String, Path, description = "MCP server name")), + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_authenticate( + Path(_name): Path<String>, + _body: Option<Json<Value>>, +) -> impl IntoResponse { + (StatusCode::OK, Json(json!({"status": "needs_auth"}))) +} + +#[utoipa::path( + post, + path = "/mcp/{name}/connect", + params(("name" = String, Path, description = "MCP server name")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_connect(Path(_name): Path<String>) -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/mcp/{name}/disconnect", + params(("name" = String, Path, description = "MCP server name")), + responses((status = 200)), + tag = "opencode" +)] +async fn oc_mcp_disconnect(Path(_name): Path<String>) -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + get, + path = "/experimental/tool/ids", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tool_ids() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + get, + path = "/experimental/tool", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tool_list(Query(query): Query<ToolQuery>) -> impl IntoResponse { + if query.provider.is_none() || query.model.is_none() { + return bad_request("provider and model are required").into_response(); + } + (StatusCode::OK, Json(json!([]))).into_response() +} + +#[utoipa::path( + get, + path = "/experimental/resource", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_resource_list() -> impl IntoResponse { + (StatusCode::OK, Json(json!({}))) +} + +#[utoipa::path( + get, + path = "/experimental/worktree", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_worktree_list(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, None); + let worktree = state.opencode.worktree_for(&directory); + (StatusCode::OK, Json(json!([worktree]))) +} + +#[utoipa::path( + post, + path = "/experimental/worktree", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_worktree_create(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, None); + let worktree = state.opencode.worktree_for(&directory); + ( + StatusCode::OK, + Json(json!({ + "name": "worktree", + "branch": state.opencode.branch_name(), + "directory": worktree, + })), + ) +} + +#[utoipa::path( + delete, + path = "/experimental/worktree", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_worktree_delete() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/experimental/worktree/reset", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_worktree_reset() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + get, + path = "/skill", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_skill_list() -> impl IntoResponse { + (StatusCode::OK, Json(json!([]))) +} + +#[utoipa::path( + get, + path = "/tui/control/next", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_next() -> impl IntoResponse { + (StatusCode::OK, Json(json!({"path": "", "body": {}}))) +} + +#[utoipa::path( + post, + path = "/tui/control/response", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_response() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/append-prompt", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_append_prompt() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/open-help", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_open_help() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/open-sessions", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_open_sessions() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/open-themes", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_open_themes() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/open-models", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_open_models() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/submit-prompt", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_submit_prompt() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/clear-prompt", + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_clear_prompt() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/execute-command", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_execute_command() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/show-toast", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_show_toast() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/publish", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_publish() -> impl IntoResponse { + bool_ok(true) +} + +#[utoipa::path( + post, + path = "/tui/select-session", + request_body = String, + responses((status = 200)), + tag = "opencode" +)] +async fn oc_tui_select_session() -> impl IntoResponse { + bool_ok(true) +} + +#[derive(OpenApi)] +#[openapi( + paths( + oc_agent_list, + oc_command_list, + oc_config_get, + oc_config_patch, + oc_config_providers, + oc_event_subscribe, + oc_global_event, + oc_global_health, + oc_global_config_get, + oc_global_config_patch, + oc_global_dispose, + oc_instance_dispose, + oc_log, + oc_lsp_status, + oc_formatter_status, + oc_path, + oc_vcs, + oc_project_list, + oc_project_current, + oc_project_update, + oc_session_create, + oc_session_list, + oc_session_get, + oc_session_update, + oc_session_delete, + oc_session_status, + oc_session_abort, + oc_session_children, + oc_session_init, + oc_session_fork, + oc_session_diff, + oc_session_summarize, + oc_session_messages, + oc_session_message_create, + oc_session_message_get, + oc_message_part_update, + oc_message_part_delete, + oc_session_prompt_async, + oc_session_command, + oc_session_shell, + oc_session_revert, + oc_session_unrevert, + oc_session_permission_reply, + oc_session_share, + oc_session_unshare, + oc_session_todo, + oc_permission_list, + oc_permission_reply, + oc_question_list, + oc_question_reply, + oc_question_reject, + oc_provider_list, + oc_provider_auth, + oc_provider_oauth_authorize, + oc_provider_oauth_callback, + oc_auth_set, + oc_auth_remove, + oc_pty_list, + oc_pty_create, + oc_pty_get, + oc_pty_update, + oc_pty_delete, + oc_pty_connect, + oc_file_list, + oc_file_content, + oc_file_status, + oc_find_text, + oc_find_files, + oc_find_symbols, + oc_mcp_list, + oc_mcp_register, + oc_mcp_auth, + oc_mcp_auth_remove, + oc_mcp_auth_callback, + oc_mcp_authenticate, + oc_mcp_connect, + oc_mcp_disconnect, + oc_tool_ids, + oc_tool_list, + oc_resource_list, + oc_worktree_list, + oc_worktree_create, + oc_worktree_delete, + oc_worktree_reset, + oc_skill_list, + oc_tui_next, + oc_tui_response, + oc_tui_append_prompt, + oc_tui_open_help, + oc_tui_open_sessions, + oc_tui_open_themes, + oc_tui_open_models, + oc_tui_submit_prompt, + oc_tui_clear_prompt, + oc_tui_execute_command, + oc_tui_show_toast, + oc_tui_publish, + oc_tui_select_session + ), + components(schemas( + OpenCodeCreateSessionRequest, + OpenCodeUpdateSessionRequest, + SessionMessageRequest, + SessionCommandRequest, + SessionShellRequest, + SessionSummarizeRequest, + PermissionReplyRequest, + PermissionGlobalReplyRequest, + QuestionReplyBody, + PtyCreateRequest + )), + tags((name = "opencode", description = "OpenCode compatibility API")) +)] +pub struct OpenCodeApiDoc; diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 5f16582..3ca437a 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -34,9 +34,12 @@ use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; use tokio::time::sleep; use tokio_stream::wrappers::BroadcastStream; use tower_http::trace::TraceLayer; +use base64::Engine; +use tracing::Span; use utoipa::{Modify, OpenApi, ToSchema}; use crate::agent_server_logs::AgentServerLogs; +use crate::opencode_compat::{build_opencode_router, OpenCodeAppState}; use crate::ui; use sandbox_agent_agent_management::agents::{ AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn, @@ -68,6 +71,10 @@ impl AppState { session_manager, } } + + pub(crate) fn session_manager(&self) -> Arc<SessionManager> { + self.session_manager.clone() + } } #[derive(Debug, Clone)] @@ -126,16 +133,78 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>) )); } + let opencode_state = OpenCodeAppState::new(shared.clone()); + let mut opencode_router = build_opencode_router(opencode_state.clone()); + let mut opencode_root_router = build_opencode_router(opencode_state); + if shared.auth.token.is_some() { + opencode_router = opencode_router.layer(axum::middleware::from_fn_with_state( + shared.clone(), + require_token, + )); + opencode_root_router = opencode_root_router.layer(axum::middleware::from_fn_with_state( + shared.clone(), + require_token, + )); + } + let mut router = Router::new() .route("/", get(get_root)) .nest("/v1", v1_router) + .nest("/opencode", opencode_router) + .merge(opencode_root_router) .fallback(not_found); if ui::is_enabled() { router = router.merge(ui::router()); } - (router.layer(TraceLayer::new_for_http()), shared) + let http_logging = match std::env::var("SANDBOX_AGENT_LOG_HTTP") { + Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false, + _ => true, + }; + if http_logging { + let include_headers = std::env::var("SANDBOX_AGENT_LOG_HTTP_HEADERS").is_ok(); + let trace_layer = TraceLayer::new_for_http() + .make_span_with(move |req: &Request<_>| { + if include_headers { + let mut headers = Vec::new(); + for (name, value) in req.headers().iter() { + let name_str = name.as_str(); + let display_value = if name_str.eq_ignore_ascii_case("authorization") { + "<redacted>".to_string() + } else { + value.to_str().unwrap_or("<binary>").to_string() + }; + headers.push((name_str.to_string(), display_value)); + } + tracing::info_span!( + "http.request", + method = %req.method(), + uri = %req.uri(), + headers = ?headers + ) + } else { + tracing::info_span!( + "http.request", + method = %req.method(), + uri = %req.uri() + ) + } + }) + .on_request(|_req: &Request<_>, span: &Span| { + tracing::info!(parent: span, "request"); + }) + .on_response(|res: &Response<_>, latency: Duration, span: &Span| { + tracing::info!( + parent: span, + status = %res.status(), + latency_ms = latency.as_millis() + ); + }); + router = router.layer(trace_layer); + } + + (router, shared) } pub async fn shutdown_servers(state: &Arc<AppState>) { @@ -744,7 +813,7 @@ struct AgentServerManager { } #[derive(Debug)] -struct SessionManager { +pub(crate) struct SessionManager { agent_manager: Arc<AgentManager>, sessions: Mutex<Vec<SessionState>>, server_manager: Arc<AgentServerManager>, @@ -847,9 +916,25 @@ impl CodexServer { } } -struct SessionSubscription { - initial_events: Vec<UniversalEvent>, - receiver: broadcast::Receiver<UniversalEvent>, +pub(crate) struct SessionSubscription { + pub(crate) initial_events: Vec<UniversalEvent>, + pub(crate) receiver: broadcast::Receiver<UniversalEvent>, +} + +#[derive(Debug, Clone)] +pub(crate) struct PendingPermissionInfo { + pub session_id: String, + pub permission_id: String, + pub action: String, + pub metadata: Option<Value>, +} + +#[derive(Debug, Clone)] +pub(crate) struct PendingQuestionInfo { + pub session_id: String, + pub question_id: String, + pub prompt: String, + pub options: Vec<String>, } impl ManagedServer { @@ -1477,7 +1562,7 @@ impl SessionManager { logs.read_stderr() } - async fn create_session( + pub(crate) async fn create_session( self: &Arc<Self>, session_id: String, request: CreateSessionRequest, @@ -1604,7 +1689,7 @@ impl SessionManager { } } - async fn send_message( + pub(crate) async fn send_message( self: &Arc<Self>, session_id: String, message: String, @@ -1819,7 +1904,7 @@ impl SessionManager { .collect() } - async fn subscribe( + pub(crate) async fn subscribe( &self, session_id: &str, offset: u64, @@ -1843,6 +1928,38 @@ impl SessionManager { }) } + pub(crate) async fn list_pending_permissions(&self) -> Vec<PendingPermissionInfo> { + let sessions = self.sessions.lock().await; + let mut items = Vec::new(); + for session in sessions.iter() { + for (permission_id, pending) in session.pending_permissions.iter() { + items.push(PendingPermissionInfo { + session_id: session.session_id.clone(), + permission_id: permission_id.clone(), + action: pending.action.clone(), + metadata: pending.metadata.clone(), + }); + } + } + items + } + + pub(crate) async fn list_pending_questions(&self) -> Vec<PendingQuestionInfo> { + let sessions = self.sessions.lock().await; + let mut items = Vec::new(); + for session in sessions.iter() { + for (question_id, pending) in session.pending_questions.iter() { + items.push(PendingQuestionInfo { + session_id: session.session_id.clone(), + question_id: question_id.clone(), + prompt: pending.prompt.clone(), + options: pending.options.clone(), + }); + } + } + items + } + async fn subscribe_for_turn( &self, session_id: &str, @@ -1871,7 +1988,7 @@ impl SessionManager { Ok((SessionSnapshot::from(session), subscription)) } - async fn reply_question( + pub(crate) async fn reply_question( &self, session_id: &str, question_id: &str, @@ -1949,7 +2066,7 @@ impl SessionManager { Ok(()) } - async fn reject_question( + pub(crate) async fn reject_question( &self, session_id: &str, question_id: &str, @@ -2028,7 +2145,7 @@ impl SessionManager { Ok(()) } - async fn reply_permission( + pub(crate) async fn reply_permission( self: &Arc<Self>, session_id: &str, permission_id: &str, @@ -3291,11 +3408,35 @@ fn extract_token(headers: &HeaderMap) -> Option<String> { if let Some(value) = headers.get(axum::http::header::AUTHORIZATION) { if let Ok(value) = value.to_str() { let value = value.trim(); - if let Some(stripped) = value.strip_prefix("Bearer ") { - return Some(stripped.to_string()); - } - if let Some(stripped) = value.strip_prefix("Token ") { - return Some(stripped.to_string()); + if let Some((scheme, rest)) = value.split_once(' ') { + let scheme_lower = scheme.to_ascii_lowercase(); + let rest = rest.trim(); + match scheme_lower.as_str() { + "bearer" | "token" => { + return Some(rest.to_string()); + } + "basic" => { + let engines = [ + base64::engine::general_purpose::STANDARD, + base64::engine::general_purpose::STANDARD_NO_PAD, + base64::engine::general_purpose::URL_SAFE, + base64::engine::general_purpose::URL_SAFE_NO_PAD, + ]; + for engine in engines { + if let Ok(decoded) = engine.decode(rest) { + if let Ok(decoded_str) = String::from_utf8(decoded) { + if let Some((_, password)) = decoded_str.split_once(':') { + return Some(password.to_string()); + } + if !decoded_str.is_empty() { + return Some(decoded_str); + } + } + } + } + } + _ => {} + } } } } diff --git a/server/packages/sandbox-agent/src/server_logs/mod.rs b/server/packages/sandbox-agent/src/server_logs/mod.rs new file mode 100644 index 0000000..fbe4539 --- /dev/null +++ b/server/packages/sandbox-agent/src/server_logs/mod.rs @@ -0,0 +1,9 @@ +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +#[cfg(unix)] +pub use unix::ServerLogs; +#[cfg(windows)] +pub use windows::ServerLogs; diff --git a/server/packages/sandbox-agent/src/server_logs/unix.rs b/server/packages/sandbox-agent/src/server_logs/unix.rs new file mode 100644 index 0000000..20bad79 --- /dev/null +++ b/server/packages/sandbox-agent/src/server_logs/unix.rs @@ -0,0 +1,103 @@ +use std::os::unix::io::AsRawFd; +use std::path::PathBuf; + +use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc}; + +pub struct ServerLogs { + path: PathBuf, + retention: Duration, + + last_rotation: chrono::DateTime<Utc>, + next_rotation: chrono::DateTime<Utc>, +} + +impl ServerLogs { + pub fn new(path: PathBuf, retention: std::time::Duration) -> Self { + Self { + path, + retention: chrono::Duration::from_std(retention).expect("invalid retention duration"), + last_rotation: Utc.timestamp_opt(0, 0).unwrap(), + next_rotation: Utc.timestamp_opt(0, 0).unwrap(), + } + } + + pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> { + std::fs::create_dir_all(&self.path)?; + self.rotate_sync()?; + + Ok(std::thread::spawn(|| self.run_sync())) + } + + fn run_sync(mut self) { + loop { + let now = Utc::now(); + + if self.next_rotation - now > Duration::seconds(5) { + std::thread::sleep( + (self.next_rotation - now - Duration::seconds(5)) + .max(TimeDelta::default()) + .to_std() + .expect("bad duration"), + ); + } else if now.ordinal() != self.last_rotation.ordinal() { + if let Err(err) = self.rotate_sync() { + tracing::error!(?err, "failed logs rotation"); + } + } else { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + } + + fn rotate_sync(&mut self) -> Result<(), std::io::Error> { + self.last_rotation = Utc::now(); + self.next_rotation = Utc.from_utc_datetime( + &(self + .last_rotation + .date_naive() + .and_hms_opt(0, 0, 0) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "invalid date") + })? + + Duration::days(1)), + ); + + let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y")); + let path = self.path.join(file_name); + + let log_file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .append(true) + .open(&path)?; + let log_fd = log_file.as_raw_fd(); + + unsafe { + libc::dup2(log_fd, libc::STDOUT_FILENO); + libc::dup2(log_fd, libc::STDERR_FILENO); + } + + self.prune_sync() + } + + fn prune_sync(&self) -> Result<(), std::io::Error> { + let mut entries = std::fs::read_dir(&self.path)?; + let mut pruned = 0; + + while let Some(entry) = entries.next() { + let entry = entry?; + let metadata = entry.metadata()?; + let modified = chrono::DateTime::<Utc>::from(metadata.modified()?); + if modified < Utc::now() - self.retention { + pruned += 1; + let _ = std::fs::remove_file(entry.path()); + } + } + + if pruned != 0 { + tracing::debug!("pruned {pruned} log files"); + } + + Ok(()) + } +} diff --git a/server/packages/sandbox-agent/src/server_logs/windows.rs b/server/packages/sandbox-agent/src/server_logs/windows.rs new file mode 100644 index 0000000..594a38c --- /dev/null +++ b/server/packages/sandbox-agent/src/server_logs/windows.rs @@ -0,0 +1,131 @@ +use std::path::PathBuf; + +use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc}; +use windows::{ + core::PCSTR, + Win32::{ + Foundation::{HANDLE, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{ + CreateFileA, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_WRITE, FILE_SHARE_READ, OPEN_ALWAYS, + }, + System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE}, + }, +}; + +pub struct ServerLogs { + path: PathBuf, + retention: Duration, + + last_rotation: chrono::DateTime<Utc>, + next_rotation: chrono::DateTime<Utc>, +} + +impl ServerLogs { + pub fn new(path: PathBuf, retention: std::time::Duration) -> Self { + Self { + path, + retention: chrono::Duration::from_std(retention).expect("invalid retention duration"), + last_rotation: Utc.timestamp_opt(0, 0).unwrap(), + next_rotation: Utc.timestamp_opt(0, 0).unwrap(), + } + } + + pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> { + std::fs::create_dir_all(&self.path)?; + self.rotate_sync()?; + + Ok(std::thread::spawn(|| self.run_sync())) + } + + fn run_sync(mut self) { + loop { + let now = Utc::now(); + + if self.next_rotation - now > Duration::seconds(5) { + std::thread::sleep( + (self.next_rotation - now - Duration::seconds(5)) + .max(TimeDelta::default()) + .to_std() + .expect("bad duration"), + ); + } else if now.ordinal() != self.last_rotation.ordinal() { + if let Err(err) = self.rotate_sync() { + tracing::error!(?err, "failed logs rotation"); + } + } else { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + } + + fn rotate_sync(&mut self) -> Result<(), std::io::Error> { + self.last_rotation = Utc::now(); + self.next_rotation = Utc.from_utc_datetime( + &(self + .last_rotation + .date_naive() + .and_hms_opt(0, 0, 0) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "invalid date") + })? + + Duration::days(1)), + ); + + let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y")); + let path = self.path.join(file_name); + + let path_str = path + .to_str() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "invalid path"))?; + let path_cstr = std::ffi::CString::new(path_str) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?; + + unsafe { + let file_handle = CreateFileA( + PCSTR(path_cstr.as_ptr() as *const u8), + FILE_GENERIC_WRITE.0, + FILE_SHARE_READ, + None, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + HANDLE(std::ptr::null_mut()), + ) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?; + + if file_handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "failed to create log file", + )); + } + + SetStdHandle(STD_OUTPUT_HANDLE, file_handle) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?; + SetStdHandle(STD_ERROR_HANDLE, file_handle) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?; + } + + self.prune_sync() + } + + fn prune_sync(&self) -> Result<(), std::io::Error> { + let mut entries = std::fs::read_dir(&self.path)?; + let mut pruned = 0; + + while let Some(entry) = entries.next() { + let entry = entry?; + let metadata = entry.metadata()?; + let modified = chrono::DateTime::<Utc>::from(metadata.modified()?); + if modified < Utc::now() - self.retention { + pruned += 1; + let _ = std::fs::remove_file(entry.path()); + } + } + + if pruned != 0 { + tracing::debug!("pruned {pruned} log files"); + } + + Ok(()) + } +} diff --git a/server/packages/sandbox-agent/tests/opencode-compat/README.md b/server/packages/sandbox-agent/tests/opencode-compat/README.md new file mode 100644 index 0000000..0167d07 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/README.md @@ -0,0 +1,81 @@ +# OpenCode Compatibility Tests + +These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints under `/opencode` and that they are usable with the official [`@opencode-ai/sdk`](https://www.npmjs.com/package/@opencode-ai/sdk) TypeScript SDK. + +## Purpose + +The goal is to enable sandbox-agent to be used as a drop-in replacement for OpenCode's server, allowing tools and integrations built for OpenCode to work seamlessly with sandbox-agent. + +## Test Coverage + +The tests cover the following OpenCode API surfaces: + +### Session Management (`session.test.ts`) +- `POST /session` - Create a new session +- `GET /session` - List all sessions +- `GET /session/{id}` - Get session details +- `PATCH /session/{id}` - Update session properties +- `DELETE /session/{id}` - Delete a session + +### Messaging (`messaging.test.ts`) +- `POST /session/{id}/message` - Send a prompt to the session +- `POST /session/{id}/prompt_async` - Send async prompt +- `GET /session/{id}/message` - List messages +- `GET /session/{id}/message/{messageID}` - Get specific message +- `POST /session/{id}/abort` - Abort session + +### Event Streaming (`events.test.ts`) +- `GET /event` - Subscribe to all events (SSE) +- `GET /global/event` - Subscribe to global events (SSE) +- `GET /session/status` - Get session status + +### Permissions (`permissions.test.ts`) +- `POST /session/{id}/permissions/{permissionID}` - Respond to permission request + +### OpenAPI Coverage (Rust) +- `cargo test -p sandbox-agent --test opencode_openapi` +- Compares the Utoipa-generated OpenCode spec against `resources/agent-schemas/artifacts/openapi/opencode.json` + +## Running Tests + +```bash +# From this directory +pnpm test + +# Or from the workspace root +pnpm --filter @sandbox-agent/opencode-compat-tests test +``` + +## Prerequisites + +1. Build the sandbox-agent binary: + ```bash + cargo build -p sandbox-agent + ``` + +2. Or set `SANDBOX_AGENT_BIN` environment variable to point to a pre-built binary. + +## Test Approach + +Each test: +1. Spawns a fresh sandbox-agent instance on a unique port +2. Uses `createOpencodeClient` from `@opencode-ai/sdk` to connect +3. Tests the OpenCode-compatible endpoints +4. Cleans up the server instance + +This ensures tests are isolated and can run in parallel. + +## Current Status + +These tests validate the `/opencode` compatibility layer and should pass when the endpoints are mounted and responding with OpenCode-compatible shapes. + +## Implementation Notes + +To make sandbox-agent OpenCode-compatible, the following needs to be implemented: + +1. **OpenCode API Routes** - Exposed under `/opencode` +2. **Request/Response Mapping** - OpenCode response shapes with stubbed data where needed +3. **SSE Event Streaming** - OpenCode event format for SSE +4. **Permission Handling** - Accepts OpenCode permission replies + +See the OpenCode SDK types at `/home/nathan/misc/opencode/packages/sdk/js/src/gen/types.gen.ts` for the expected API shapes. diff --git a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts new file mode 100644 index 0000000..587ebf3 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for OpenCode-compatible event streaming endpoints. + * + * These tests verify that sandbox-agent exposes OpenCode-compatible SSE event + * endpoints that can be used with the official OpenCode SDK. + * + * Expected endpoints: + * - GET /event - Subscribe to all events (SSE) + * - GET /global/event - Subscribe to global events (SSE) + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Event Streaming", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ opencodeCompat: true }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + describe("event.subscribe", () => { + it("should connect to SSE endpoint", async () => { + // The event.subscribe returns an SSE stream + const response = await client.event.subscribe(); + + expect(response).toBeDefined(); + expect((response as any).stream).toBeDefined(); + }); + + it("should receive session.created event when session is created", async () => { + const events: any[] = []; + + // Start listening for events + const eventStream = await client.event.subscribe(); + + // Set up event collection with timeout + const collectEvents = new Promise<void>((resolve) => { + const timeout = setTimeout(resolve, 5000); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + events.push(event); + if (event.type === "session.created") { + clearTimeout(timeout); + resolve(); + break; + } + } + } catch { + // Stream ended or errored + } + })(); + }); + + // Create a session + await client.session.create({ body: { title: "Event Test" } }); + + await collectEvents; + + // Should have received at least one session.created event + const sessionCreatedEvent = events.find((e) => e.type === "session.created"); + expect(sessionCreatedEvent).toBeDefined(); + }); + + it("should receive message.part.updated events during prompt", async () => { + const events: any[] = []; + + // Create a session first + const session = await client.session.create(); + const sessionId = session.data?.id!; + + // Start listening for events + const eventStream = await client.event.subscribe(); + + const collectEvents = new Promise<void>((resolve) => { + const timeout = setTimeout(resolve, 10000); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + events.push(event); + // Look for message part updates or completion + if ( + event.type === "message.part.updated" || + event.type === "session.idle" + ) { + if (events.length >= 3) { + clearTimeout(timeout); + resolve(); + break; + } + } + } + } catch { + // Stream ended + } + })(); + }); + + // Send a prompt + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Say hello" }], + }, + }); + + await collectEvents; + + // Should have received some events + expect(events.length).toBeGreaterThan(0); + }); + }); + + describe("global.event", () => { + it("should connect to global SSE endpoint", async () => { + const response = await client.global.event(); + + expect(response).toBeDefined(); + }); + }); + + describe("session.status", () => { + it("should return session status", async () => { + const session = await client.session.create(); + const sessionId = session.data?.id!; + + const response = await client.session.status(); + + expect(response.data).toBeDefined(); + }); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/helpers/spawn.ts b/server/packages/sandbox-agent/tests/opencode-compat/helpers/spawn.ts new file mode 100644 index 0000000..3d041e7 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/helpers/spawn.ts @@ -0,0 +1,237 @@ +/** + * Utilities for spawning sandbox-agent for OpenCode compatibility testing. + * Mirrors the patterns from sdks/typescript/src/spawn.ts + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer, type AddressInfo, type Server } from "node:net"; +import { existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface SandboxAgentHandle { + baseUrl: string; + token: string; + child: ChildProcess; + dispose: () => Promise<void>; +} + +/** + * Find the sandbox-agent binary in common locations + */ +function findBinary(): string | null { + // Check environment variable first + if (process.env.SANDBOX_AGENT_BIN) { + const path = process.env.SANDBOX_AGENT_BIN; + if (existsSync(path)) { + return path; + } + } + + // Check cargo build outputs (relative to tests/opencode-compat/helpers) + const cargoPaths = [ + resolve(__dirname, "../../../../../../target/debug/sandbox-agent"), + resolve(__dirname, "../../../../../../target/release/sandbox-agent"), + ]; + + for (const p of cargoPaths) { + if (existsSync(p)) { + return p; + } + } + + return null; +} + +/** + * Get a free port on the given host + */ +async function getFreePort(host: string): Promise<number> { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, host, () => { + const address = server.address() as AddressInfo; + server.close(() => resolve(address.port)); + }); + }); +} + +/** + * Wait for the server to become healthy + */ +async function waitForHealth( + baseUrl: string, + token: string, + timeoutMs: number, + child: ChildProcess +): Promise<void> { + const start = Date.now(); + let lastError: string | undefined; + + while (Date.now() - start < timeoutMs) { + if (child.exitCode !== null) { + throw new Error("sandbox-agent exited before becoming healthy"); + } + + try { + const response = await fetch(`${baseUrl}/v1/health`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + return; + } + lastError = `status ${response.status}`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + + await new Promise((r) => setTimeout(r, 200)); + } + + throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown"})`); +} + +/** + * Wait for child process to exit + */ +async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> { + if (child.exitCode !== null) { + return true; + } + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + child.once("exit", () => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +export interface SpawnOptions { + host?: string; + port?: number; + token?: string; + timeoutMs?: number; + env?: Record<string, string>; + /** Enable OpenCode compatibility mode */ + opencodeCompat?: boolean; +} + +/** + * Spawn a sandbox-agent instance for testing. + * Each test should spawn its own instance on a unique port. + */ +export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<SandboxAgentHandle> { + const binaryPath = findBinary(); + if (!binaryPath) { + throw new Error( + "sandbox-agent binary not found. Run 'cargo build -p sandbox-agent' first or set SANDBOX_AGENT_BIN." + ); + } + + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? (await getFreePort(host)); + const token = options.token ?? randomBytes(24).toString("hex"); + const timeoutMs = options.timeoutMs ?? 30_000; + + const args = ["server", "--host", host, "--port", String(port), "--token", token]; + + const compatEnv = { + OPENCODE_COMPAT_FIXED_TIME_MS: "1700000000000", + OPENCODE_COMPAT_DIRECTORY: "/workspace", + OPENCODE_COMPAT_WORKTREE: "/workspace", + OPENCODE_COMPAT_HOME: "/home/opencode", + OPENCODE_COMPAT_STATE: "/state/opencode", + OPENCODE_COMPAT_CONFIG: "/config/opencode", + OPENCODE_COMPAT_BRANCH: "main", + }; + + const child = spawn(binaryPath, args, { + stdio: "pipe", + env: { + ...process.env, + ...compatEnv, + ...(options.env ?? {}), + }, + }); + + // Collect stderr for debugging + let stderr = ""; + child.stderr?.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + if (process.env.SANDBOX_AGENT_TEST_LOGS) { + process.stderr.write(text); + } + }); + if (process.env.SANDBOX_AGENT_TEST_LOGS) { + child.stdout?.on("data", (chunk) => { + process.stderr.write(chunk.toString()); + }); + } + + const baseUrl = `http://${host}:${port}`; + + try { + await waitForHealth(baseUrl, token, timeoutMs, child); + } catch (err) { + child.kill("SIGKILL"); + if (stderr) { + throw new Error(`${err}. Stderr: ${stderr}`); + } + throw err; + } + + const dispose = async () => { + if (child.exitCode !== null) { + return; + } + child.kill("SIGTERM"); + const exited = await waitForExit(child, 5_000); + if (!exited) { + child.kill("SIGKILL"); + } + }; + + return { baseUrl, token, child, dispose }; +} + +/** + * Build the sandbox-agent binary if it doesn't exist + */ +export async function buildSandboxAgent(): Promise<void> { + const binaryPath = findBinary(); + if (binaryPath) { + console.log(`sandbox-agent binary found at: ${binaryPath}`); + return; + } + + console.log("Building sandbox-agent..."); + const projectRoot = resolve(__dirname, "../../../../../.."); + + return new Promise((resolve, reject) => { + const proc = spawn("cargo", ["build", "-p", "sandbox-agent"], { + cwd: projectRoot, + stdio: "inherit", + env: { + ...process.env, + SANDBOX_AGENT_SKIP_INSPECTOR: "1", + }, + }); + + proc.on("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`cargo build failed with code ${code}`)); + } + }); + + proc.on("error", reject); + }); +} diff --git a/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts new file mode 100644 index 0000000..f45db83 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for OpenCode-compatible messaging endpoints. + * + * These tests verify that sandbox-agent exposes OpenCode-compatible message/prompt + * endpoints that can be used with the official OpenCode SDK. + * + * Expected endpoints: + * - POST /session/{id}/message - Send a prompt to the session + * - GET /session/{id}/message - List messages in a session + * - GET /session/{id}/message/{messageID} - Get a specific message + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Messaging API", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + let sessionId: string; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ opencodeCompat: true }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + + // Create a session for messaging tests + const session = await client.session.create(); + sessionId = session.data?.id!; + expect(sessionId).toBeDefined(); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + describe("session.prompt", () => { + it("should send a message to the session", async () => { + const response = await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Hello, world!" }], + }, + }); + + // The response should return a message or acknowledgement + expect(response.error).toBeUndefined(); + }); + + it("should accept text-only prompt", async () => { + const response = await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Say hello" }], + }, + }); + + expect(response.error).toBeUndefined(); + }); + }); + + describe("session.promptAsync", () => { + it("should send async prompt and return immediately", async () => { + const response = await client.session.promptAsync({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Process this asynchronously" }], + }, + }); + + // Should return quickly without waiting for completion + expect(response.error).toBeUndefined(); + }); + }); + + describe("session.messages", () => { + it("should return empty list for new session", async () => { + const response = await client.session.messages({ + path: { id: sessionId }, + }); + + expect(response.data).toBeDefined(); + expect(Array.isArray(response.data)).toBe(true); + }); + + it("should list messages after sending a prompt", async () => { + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Test message" }], + }, + }); + + const response = await client.session.messages({ + path: { id: sessionId }, + }); + + expect(response.data).toBeDefined(); + expect(response.data?.length).toBeGreaterThan(0); + }); + }); + + describe("session.message (get specific)", () => { + it("should retrieve a specific message by ID", async () => { + // Send a prompt first + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Test" }], + }, + }); + + // Get messages to find a message ID + const messagesResponse = await client.session.messages({ + path: { id: sessionId }, + }); + const messageId = messagesResponse.data?.[0]?.id; + + if (messageId) { + const response = await client.session.message({ + path: { id: sessionId, messageID: messageId }, + }); + + expect(response.data).toBeDefined(); + expect(response.data?.id).toBe(messageId); + } + }); + }); + + describe("session.abort", () => { + it("should abort an in-progress session", async () => { + // Start an async prompt + await client.session.promptAsync({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Long running task" }], + }, + }); + + // Abort the session + const response = await client.session.abort({ + path: { id: sessionId }, + }); + + expect(response.error).toBeUndefined(); + }); + + it("should handle abort on idle session gracefully", async () => { + // Abort without starting any work + const response = await client.session.abort({ + path: { id: sessionId }, + }); + + // Should not error, even if there's nothing to abort + expect(response.error).toBeUndefined(); + }); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/package.json b/server/packages/sandbox-agent/tests/opencode-compat/package.json new file mode 100644 index 0000000..60cce21 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/package.json @@ -0,0 +1,18 @@ +{ + "name": "@sandbox-agent/opencode-compat-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "@opencode-ai/sdk": "^1.1.21" + } +} diff --git a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts new file mode 100644 index 0000000..097d9fe --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for OpenCode-compatible permission endpoints. + * + * These tests verify that sandbox-agent exposes OpenCode-compatible permission + * handling endpoints that can be used with the official OpenCode SDK. + * + * Expected endpoints: + * - POST /session/{id}/permissions/{permissionID} - Respond to a permission request + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Permission API", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + let sessionId: string; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ opencodeCompat: true }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + + // Create a session + const session = await client.session.create(); + sessionId = session.data?.id!; + expect(sessionId).toBeDefined(); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + const permissionPrompt = "permission"; + + async function waitForPermissionRequest(timeoutMs = 10_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const list = await client.permission.list(); + const request = list.data?.[0]; + if (request) { + return request; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error("Timed out waiting for permission request"); + } + + describe("permission.reply (global)", () => { + it("should receive permission.asked and reply via global endpoint", async () => { + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const asked = await waitForPermissionRequest(); + const requestId = asked?.id; + expect(requestId).toBeDefined(); + + const response = await client.permission.reply({ + requestID: requestId, + reply: "once", + }); + expect(response.error).toBeUndefined(); + }); + }); + + describe("postSessionIdPermissionsPermissionId (session)", () => { + it("should accept permission response for a session", async () => { + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const asked = await waitForPermissionRequest(); + const requestId = asked?.id; + expect(requestId).toBeDefined(); + + const response = await client.permission.respond({ + sessionID: sessionId, + permissionID: requestId, + response: "allow", + }); + + expect(response.error).toBeUndefined(); + }); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts new file mode 100644 index 0000000..ae881fb --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for OpenCode-compatible question endpoints. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Question API", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + let sessionId: string; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ opencodeCompat: true }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + + const session = await client.session.create(); + sessionId = session.data?.id!; + expect(sessionId).toBeDefined(); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + const questionPrompt = "question"; + + async function waitForQuestionRequest(timeoutMs = 10_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const list = await client.question.list(); + const request = list.data?.[0]; + if (request) { + return request; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error("Timed out waiting for question request"); + } + + it("should ask a question and accept a reply", async () => { + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: questionPrompt }], + }); + + const asked = await waitForQuestionRequest(); + const requestId = asked?.id; + expect(requestId).toBeDefined(); + + const replyResponse = await client.question.reply({ + requestID: requestId, + answers: [["Yes"]], + }); + expect(replyResponse.error).toBeUndefined(); + }); + + it("should allow rejecting a question", async () => { + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: questionPrompt }], + }); + + const asked = await waitForQuestionRequest(); + const requestId = asked?.id; + expect(requestId).toBeDefined(); + + const rejectResponse = await client.question.reject({ + requestID: requestId, + }); + expect(rejectResponse.error).toBeUndefined(); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts new file mode 100644 index 0000000..c778691 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for OpenCode-compatible session management endpoints. + * + * These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints + * that can be used with the official OpenCode SDK. + * + * Expected endpoints: + * - POST /session - Create a new session + * - GET /session - List all sessions + * - GET /session/{id} - Get session details + * - PATCH /session/{id} - Update session properties + * - DELETE /session/{id} - Delete a session + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Session API", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + + beforeAll(async () => { + // Build the binary if needed + await buildSandboxAgent(); + }); + + beforeEach(async () => { + // Spawn a fresh sandbox-agent instance for each test + handle = await spawnSandboxAgent({ opencodeCompat: true }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + describe("session.create", () => { + it("should create a new session", async () => { + const response = await client.session.create(); + + expect(response.data).toBeDefined(); + expect(response.data?.id).toBeDefined(); + expect(typeof response.data?.id).toBe("string"); + expect(response.data?.id.length).toBeGreaterThan(0); + }); + + it("should create session with custom title", async () => { + const response = await client.session.create({ + body: { title: "Test Session" }, + }); + + expect(response.data).toBeDefined(); + expect(response.data?.title).toBe("Test Session"); + }); + + it("should assign unique IDs to each session", async () => { + const session1 = await client.session.create(); + const session2 = await client.session.create(); + + expect(session1.data?.id).not.toBe(session2.data?.id); + }); + }); + + describe("session.list", () => { + it("should return empty list when no sessions exist", async () => { + const response = await client.session.list(); + + expect(response.data).toBeDefined(); + expect(Array.isArray(response.data)).toBe(true); + expect(response.data?.length).toBe(0); + }); + + it("should list created sessions", async () => { + // Create some sessions + await client.session.create({ body: { title: "Session 1" } }); + await client.session.create({ body: { title: "Session 2" } }); + + const response = await client.session.list(); + + expect(response.data).toBeDefined(); + expect(response.data?.length).toBe(2); + }); + }); + + describe("session.get", () => { + it("should retrieve session by ID", async () => { + const created = await client.session.create({ body: { title: "Test" } }); + const sessionId = created.data?.id; + expect(sessionId).toBeDefined(); + + const response = await client.session.get({ path: { id: sessionId! } }); + + expect(response.data).toBeDefined(); + expect(response.data?.id).toBe(sessionId); + expect(response.data?.title).toBe("Test"); + }); + + it("should return error for non-existent session", async () => { + const response = await client.session.get({ + path: { id: "non-existent-session-id" }, + }); + + expect(response.error).toBeDefined(); + }); + }); + + describe("session.update", () => { + it("should update session title", async () => { + const created = await client.session.create({ body: { title: "Original" } }); + const sessionId = created.data?.id!; + + await client.session.update({ + path: { id: sessionId }, + body: { title: "Updated" }, + }); + + const response = await client.session.get({ path: { id: sessionId } }); + expect(response.data?.title).toBe("Updated"); + }); + }); + + describe("session.delete", () => { + it("should delete a session", async () => { + const created = await client.session.create(); + const sessionId = created.data?.id!; + + await client.session.delete({ path: { id: sessionId } }); + + const response = await client.session.get({ path: { id: sessionId } }); + expect(response.error).toBeDefined(); + }); + + it("should not affect other sessions when one is deleted", async () => { + const session1 = await client.session.create({ body: { title: "Keep" } }); + const session2 = await client.session.create({ body: { title: "Delete" } }); + + await client.session.delete({ path: { id: session2.data?.id! } }); + + const response = await client.session.get({ path: { id: session1.data?.id! } }); + expect(response.data).toBeDefined(); + expect(response.data?.title).toBe("Keep"); + }); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts new file mode 100644 index 0000000..4cdda8f --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for OpenCode-compatible tool calls and file actions. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Tool + File Actions", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + let sessionId: string; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ opencodeCompat: true }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + + const session = await client.session.create(); + sessionId = session.data?.id!; + expect(sessionId).toBeDefined(); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + it("should emit tool and file parts plus file.edited events", async () => { + const eventStream = await client.event.subscribe(); + const tracker = { + tool: false, + file: false, + edited: false, + }; + + const waiter = new Promise<void>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for tool events")), 15_000); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event.type === "message.part.updated") { + const part = event.properties?.part; + if (part?.type === "tool") { + tracker.tool = true; + } + if (part?.type === "file") { + tracker.file = true; + } + } + if (event.type === "file.edited") { + tracker.edited = true; + } + if (tracker.tool && tracker.file && tracker.edited) { + clearTimeout(timeout); + resolve(); + break; + } + } + } catch (err) { + clearTimeout(timeout); + reject(err); + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "tool" }], + }, + }); + + await waiter; + expect(tracker.tool).toBe(true); + expect(tracker.file).toBe(true); + expect(tracker.edited).toBe(true); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/tsconfig.json b/server/packages/sandbox-agent/tests/opencode-compat/tsconfig.json new file mode 100644 index 0000000..652f000 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/server/packages/sandbox-agent/tests/opencode-compat/vitest.config.ts b/server/packages/sandbox-agent/tests/opencode-compat/vitest.config.ts new file mode 100644 index 0000000..9cb0df2 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "node:path"; +import { realpathSync } from "node:fs"; + +// Resolve the actual SDK path through pnpm's symlink structure +function resolveSdkPath(): string { + try { + // Try to resolve through the local node_modules symlink + const localPath = resolve(__dirname, "node_modules/@opencode-ai/sdk"); + const realPath = realpathSync(localPath); + return resolve(realPath, "dist"); + } catch { + // Fallback to root node_modules + return resolve(__dirname, "../../../../../node_modules/@opencode-ai/sdk/dist"); + } +} + +export default defineConfig({ + test: { + include: ["**/*.test.ts"], + testTimeout: 60_000, + hookTimeout: 60_000, + }, + resolve: { + alias: { + // Work around SDK publishing issue where exports point to src/ instead of dist/ + "@opencode-ai/sdk": resolveSdkPath(), + }, + }, +}); diff --git a/server/packages/sandbox-agent/tests/opencode_openapi.rs b/server/packages/sandbox-agent/tests/opencode_openapi.rs new file mode 100644 index 0000000..e4d9c79 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode_openapi.rs @@ -0,0 +1,73 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +use sandbox_agent::opencode_compat::OpenCodeApiDoc; +use serde_json::Value; +use utoipa::OpenApi; + +fn collect_path_methods(spec: &Value) -> BTreeSet<String> { + let mut methods = BTreeSet::new(); + let Some(paths) = spec.get("paths").and_then(|value| value.as_object()) else { + return methods; + }; + for (path, item) in paths { + let Some(item) = item.as_object() else { + continue; + }; + for method in [ + "get", "post", "put", "patch", "delete", "options", "head", "trace", + ] { + if item.contains_key(method) { + methods.insert(format!("{} {}", method.to_uppercase(), path)); + } + } + } + methods +} + +fn official_spec_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../resources/agent-schemas/artifacts/openapi/opencode.json") +} + +#[test] +fn opencode_openapi_matches_official_paths() { + let official_path = official_spec_path(); + let official_json = fs::read_to_string(&official_path) + .unwrap_or_else(|err| panic!("failed to read official OpenCode spec at {official_path:?}: {err}")); + let official: Value = + serde_json::from_str(&official_json).expect("official OpenCode spec is not valid JSON"); + + let ours = OpenCodeApiDoc::openapi(); + let ours_value = serde_json::to_value(&ours).expect("failed to serialize OpenCode OpenAPI"); + + let official_methods = collect_path_methods(&official); + let our_methods = collect_path_methods(&ours_value); + + let missing: Vec<_> = official_methods + .difference(&our_methods) + .cloned() + .collect(); + let extra: Vec<_> = our_methods + .difference(&official_methods) + .cloned() + .collect(); + + if !missing.is_empty() || !extra.is_empty() { + let mut message = String::new(); + if !missing.is_empty() { + message.push_str("Missing endpoints (present in official spec, absent in ours):\n"); + for endpoint in &missing { + message.push_str(&format!("- {endpoint}\n")); + } + } + if !extra.is_empty() { + message.push_str("Extra endpoints (present in ours, absent in official spec):\n"); + for endpoint in &extra { + message.push_str(&format!("- {endpoint}\n")); + } + } + panic!("{message}"); + } +} diff --git a/server/packages/universal-agent-schema/src/agents/opencode.rs b/server/packages/universal-agent-schema/src/agents/opencode.rs index ee0941c..4dad152 100644 --- a/server/packages/universal-agent-schema/src/agents/opencode.rs +++ b/server/packages/universal-agent-schema/src/agents/opencode.rs @@ -43,7 +43,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, let (session_id, message_id) = part_session_message(part); match part { - schema::Part::Variant0(text_part) => { + schema::Part::TextPart(text_part) => { let schema::TextPart { text, .. } = text_part; let delta_text = delta.as_ref().unwrap_or(&text).clone(); let stub = stub_message_item(&message_id, ItemRole::Assistant); @@ -68,7 +68,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, .with_raw(raw.clone()), ); } - schema::Part::Variant2(reasoning_part) => { + schema::Part::ReasoningPart(reasoning_part) => { let delta_text = delta .as_ref() .cloned() @@ -95,7 +95,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, .with_raw(raw.clone()), ); } - schema::Part::Variant3(file_part) => { + schema::Part::FilePart(file_part) => { let file_content = file_part_to_content(file_part); let item = UniversalItem { item_id: String::new(), @@ -115,7 +115,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, .with_raw(raw.clone()), ); } - schema::Part::Variant4(tool_part) => { + schema::Part::ToolPart(tool_part) => { let tool_events = tool_part_to_events(&tool_part, &message_id); for event in tool_events { events.push( @@ -125,9 +125,9 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, ); } } - schema::Part::Variant1 { .. } => { - let detail = - serde_json::to_string(part).unwrap_or_else(|_| "subtask".to_string()); + schema::Part::SubtaskPart(subtask_part) => { + let detail = serde_json::to_string(subtask_part) + .unwrap_or_else(|_| "subtask".to_string()); let item = status_item("subtask", Some(detail)); events.push( EventConversion::new( @@ -138,13 +138,13 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, .with_raw(raw.clone()), ); } - schema::Part::Variant5(_) - | schema::Part::Variant6(_) - | schema::Part::Variant7(_) - | schema::Part::Variant8(_) - | schema::Part::Variant9(_) - | schema::Part::Variant10(_) - | schema::Part::Variant11(_) => { + schema::Part::StepStartPart(_) + | schema::Part::StepFinishPart(_) + | schema::Part::SnapshotPart(_) + | schema::Part::PatchPart(_) + | schema::Part::AgentPart(_) + | schema::Part::RetryPart(_) + | schema::Part::CompactionPart(_) => { let detail = serde_json::to_string(part).unwrap_or_else(|_| "part".to_string()); let item = status_item("part.updated", Some(detail)); events.push( @@ -306,52 +306,51 @@ fn message_to_item(message: &schema::Message) -> (UniversalItem, bool, Option<St fn part_session_message(part: &schema::Part) -> (Option<String>, String) { match part { - schema::Part::Variant0(text_part) => ( + schema::Part::TextPart(text_part) => ( Some(text_part.session_id.clone()), text_part.message_id.clone(), ), - schema::Part::Variant1 { - session_id, - message_id, - .. - } => (Some(session_id.clone()), message_id.clone()), - schema::Part::Variant2(reasoning_part) => ( + schema::Part::SubtaskPart(subtask_part) => ( + Some(subtask_part.session_id.clone()), + subtask_part.message_id.clone(), + ), + schema::Part::ReasoningPart(reasoning_part) => ( Some(reasoning_part.session_id.clone()), reasoning_part.message_id.clone(), ), - schema::Part::Variant3(file_part) => ( + schema::Part::FilePart(file_part) => ( Some(file_part.session_id.clone()), file_part.message_id.clone(), ), - schema::Part::Variant4(tool_part) => ( + schema::Part::ToolPart(tool_part) => ( Some(tool_part.session_id.clone()), tool_part.message_id.clone(), ), - schema::Part::Variant5(step_part) => ( + schema::Part::StepStartPart(step_part) => ( Some(step_part.session_id.clone()), step_part.message_id.clone(), ), - schema::Part::Variant6(step_part) => ( + schema::Part::StepFinishPart(step_part) => ( Some(step_part.session_id.clone()), step_part.message_id.clone(), ), - schema::Part::Variant7(snapshot_part) => ( + schema::Part::SnapshotPart(snapshot_part) => ( Some(snapshot_part.session_id.clone()), snapshot_part.message_id.clone(), ), - schema::Part::Variant8(patch_part) => ( + schema::Part::PatchPart(patch_part) => ( Some(patch_part.session_id.clone()), patch_part.message_id.clone(), ), - schema::Part::Variant9(agent_part) => ( + schema::Part::AgentPart(agent_part) => ( Some(agent_part.session_id.clone()), agent_part.message_id.clone(), ), - schema::Part::Variant10(retry_part) => ( + schema::Part::RetryPart(retry_part) => ( Some(retry_part.session_id.clone()), retry_part.message_id.clone(), ), - schema::Part::Variant11(compaction_part) => ( + schema::Part::CompactionPart(compaction_part) => ( Some(compaction_part.session_id.clone()), compaction_part.message_id.clone(), ),