diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 8b3b8ef04..b830a1891 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -103,6 +103,18 @@ export default function EditConnectionScreen() { return } + // Still waking is not a failure and not worth a diagnostic report — the + // report would only contradict itself a second later, which is what the + // button used to do for sleeping workspaces. + if (result.wakeSeconds !== undefined) { + setIsTesting(false) + Alert.alert( + t("sessionsList.empty.workspaceWakingTitle"), + t("sessionsList.empty.workspaceWakingCountdown", { seconds: Math.ceil(result.wakeSeconds) }), + ) + return + } + // Failed: run active diagnostics, capture to Sentry, offer a shareable report. const report = await probeConnection(url.trim(), buildAuth(username, password)) captureDiagnostic(report) diff --git a/app/connection/add.tsx b/app/connection/add.tsx index 22d588735..f3a65041f 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -108,6 +108,14 @@ export default function AddConnectionScreen() { t("connection.shared.alerts.saveFailedMessage"), ) } + } else if (result.wakeSeconds !== undefined) { + // Still waking is not a failure and not worth a diagnostic report — the + // report would only contradict itself a second later. + setIsConnecting(false) + Alert.alert( + t("sessionsList.empty.workspaceWakingTitle"), + t("sessionsList.empty.workspaceWakingCountdown", { seconds: Math.ceil(result.wakeSeconds) }), + ) } else { // Failed: run active diagnostics, capture to Sentry, offer a shareable report. const report = await probeConnection(serverUrl, buildAuth(undefined, password)) diff --git a/docs/opencode-v2-port/BRIEF.md b/docs/opencode-v2-port/BRIEF.md new file mode 100644 index 000000000..45a389ea9 --- /dev/null +++ b/docs/opencode-v2-port/BRIEF.md @@ -0,0 +1,96 @@ +# Port the app to the opencode 2 server API — brief (2026-11-11) + +Written from the DGX side (forge). Forge workspaces now run either opencode **1.18.32** (default) or **2.x** +(trial: `forge opencode 2`; the trial workspace `oc2-trial` runs **2.0.29**, the version sbx 0.51's opencode +kit ships). The app talks only the v1 API, so it cannot use a v2 workspace. Goal: the app works against both, so +the workspaces can move to v2 one at a time. + +Files next to this brief, all captured from a real 2.0.29 server: +- `openapi-2.0.29.json` — the server's own `/openapi.json` (the source of truth for routes and schemas) +- `sample-messages.json` — `GET /api/session/{id}/message` for a chat with reasoning, a shell tool call and text +- `sample-session.json` — `GET /api/session/{id}` +- `sample-events.sse` — the `/api/event` stream for that whole chat (session created → tool call → idle) + +## Recommended shape + +- **Keep the screens and stores' internal types.** Add a v2 adapter in the client layer (`src/lib/sdk.ts` or a + sibling) that maps v2 responses and events into the shapes the stores already use (Session, Message, Part, + the `message.updated` / `message.part.updated` updates the event store consumes). +- **Detect per connection:** `GET /api/info` (with auth) → `application/json` `{"version":"2.x",…}` = v2. A 1.x + server answers the same request with **200 and its web app's HTML** (checked on 1.18.32), so test the content + type / parse result, not the status. Anything that isn't v2 JSON = v1. Cache per server; re-detect on reconnect. +- Both majors must keep working; existing 1.x workspaces are the default for a while. +- Extend `tests/fixtures/mock-opencode-server.ts` with a v2 mode built from the samples, and cover the adapter + with tests (message mapping, event mapping incl. streaming deltas, permission/question round trip). + +## What is the same + +- Basic auth, user `opencode`, same password; 401 carries `WWW-Authenticate: Basic realm="Secure Area"`. +- Same forge URLs/ports; forge's own routes are unchanged: `/__forge/pr`, `/__forge/file`, `/__forge/apk`. +- The workspace "waking" behaviour of forge's front is unchanged (503 + Retry-After while it starts). + +## What changed + +- Everything is under **`/api/*`**. Unknown non-API paths return the v2 web app's HTML with **200** — check the + content type; never treat a 200 as JSON blindly. +- Folder selection: **`?location[directory]=`** (deepObject; URL-encode the brackets), not + `?directory=`. Request bodies use `"location": {"directory": "…"}`. Responses are wrapped: + `{"data": …}`, often with `"location"` and list `"cursor": {"previous","next"}` (paginated). +- Projects: `GET /api/location?location[directory]=…` resolves (and creates) the project for a folder; + `GET /api/project` lists them (`canonical` = folder). Forge registers every repo this way. + +### Route map (v1 call the app makes today → v2) + +| App use (from forge access logs) | v1 | v2 (check `openapi-2.0.29.json`) | +|---|---|---| +| health | `/global/health` | `/api/info` | +| change detection | `/vcs`, `/vcs/diff` | `/api/vcs`, `/api/vcs/status`, `/api/vcs/diff`, `/api/vcs/branch` | +| chat list | `/experimental/session`, `/session` | `GET /api/session` (paginated) , `GET /api/session/active` (running ones, server-wide) | +| chat | `GET/PATCH/DELETE /session/{id}` | `/api/session/{id}` | +| new chat | `POST /session` | `POST /api/session` `{location:{directory}, title?, agent?, model?}` | +| messages | `/session/{id}/message` | `/api/session/{id}/message` — **newest first**, cursor-paginated; `/message/{messageID}` for one | +| send | `POST /session/{id}/prompt_async` | `POST /api/session/{id}/prompt` `{text, files?, agents?, skills?}` (returns the admitted user message; work continues async) | +| stop | `POST /session/{id}/abort` | `POST /api/session/{id}/interrupt` | +| revert / unrevert | `/session/{id}/revert`, `/unrevert` | `/api/session/{id}/revert/stage` + `/revert/commit`, `DELETE /api/session/{id}/revert` | +| chat diff | `/session/{id}/diff` | `/api/session/{id}/diff` | +| events | `/global/event` | `GET /api/event` (SSE, all folders) | +| permissions | `GET /permission`, `POST /permission/{id}/reply` | `GET /api/permission/request?location[directory]=`, `POST /api/session/{sessionID}/permission/{requestID}/reply` | +| questions | `GET /question`, `POST /question/{id}/reply` | "forms": `GET /api/form?location[directory]=`, `POST /api/session/{sessionID}/form/{formID}/reply` | +| models | `/provider` | `/api/provider`, `/api/model`, `/api/model/default` | +| commands / agents | `/command`, `/agent` | `/api/command`, `/api/agent` | +| paths | `/path`, `/project/current` | `/api/location` | +| files | `/file`, `/file/roots` | `/api/fs/list`, `/api/fs/read/*`, `/api/fs/find` | +| terminal | `/api/pty…` | same family under `/api/pty` (already used) | + +### Messages (see `sample-messages.json`) + +- One list of entries with `type`: `user` (`text`, files), `assistant` (`agent`, `model{id,providerID}`, + `content[]`, `finish`, `tokens`, `cost`, `snapshot{start,end,files}`), and markers such as `idle` + (`outcome`). Assistant `content[]` items: `reasoning` (`text`, `time`), `text` (`text`), `tool` (`id`, `name`, + `state{status,input,content[],metadata}`, `time{created,ran,completed}`). Tool names changed too + (`bash`→`shell`, `task`→`subagent`, `write`/`patch`→`edit`). +- v1 had separate message + parts; map `content[i]` to parts (stable part id = message id + index or the tool `id`). + +### Events (see `sample-events.sse`) + +- `data: {"id","created","type","location":{"directory"},"data":{…},"durable":{…}}` plus `: heartbeat` lines. +- Granular instead of whole parts: `session.created`, `session.execution.started|succeeded`, + `session.step.started|ended`, `session.reasoning.started|delta|ended`, `session.text.started|delta|ended`, + `session.tool.input.started|ended`, `session.tool.called|progress|success`, `session.usage.updated`, + `session.inbox.enqueued|delivered`, `shell.created|exited`, and permission/form events (not yet captured — + trigger one against a v2 server and record it). The adapter has to accumulate deltas into the current part and + emit the app's existing update shape; on reconnect, refetch the open chat (the stream is volatile: missed events + are gone). + +## How to test against a real v2 server + +- This sandbox predates sbx 0.51 and has no v2 binary. Install one into its own prefix and run it on a spare port: + `npm i --prefix ~/oc2 @opencode/cli@2.0.29` → `~/oc2/node_modules/.bin/opencode serve --hostname 127.0.0.1 --port 4196` + with `OPENCODE_SERVER_PASSWORD` set and `OPENCODE_CONFIG=/home/agent/.forge/opencode.json` (the dgx model config; + v2 accepts the v1 keys). Use **2.0.29** (what the workspaces run); npm's latest is newer (2.2.x at the time of + writing) and the API may have moved — check `/openapi.json` of whatever you run. +- On the phone: the trial workspace `oc2-trial` (port 4305) runs v2; build a dev APK (`/__forge/apk`) and point + it there. +- Watch the app's network calls against both majors; the 1.x path must not regress. + +Not in scope: changing forge itself (front/forge already handle both majors), migrating old 1.x chats. diff --git a/docs/opencode-v2-port/openapi-2.0.29.json b/docs/opencode-v2-port/openapi-2.0.29.json new file mode 100644 index 000000000..ad0459af3 --- /dev/null +++ b/docs/opencode-v2-port/openapi-2.0.29.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"opencode HttpApi","version":"0.0.1","description":"Experimental HttpApi surface for selected instance routes."},"paths":{"/api/info":{"get":{"tags":["server"],"operationId":"server.info","parameters":[],"security":[],"responses":{"200":{"description":"ServerInfo","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfo"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Return the server identity, connection URLs, paths, and readiness status.","summary":"Get server info"}},"/api/pair":{"post":{"tags":["server"],"operationId":"server.pair","parameters":[],"security":[],"responses":{"200":{"description":"PairingCode","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PairingCode"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Create a short-lived, single-use code for a /auth/connect/:code pairing link.","summary":"Create pairing code"}},"/auth/connect/{code}":{"get":{"tags":["server"],"operationId":"server.connect","parameters":[{"name":"code","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"PairingSession","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PairingSession"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.","summary":"Redeem pairing code"}},"/api/location":{"get":{"tags":["location"],"operationId":"location.get","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Location.PublicInfo","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Location.PublicInfo"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Resolve the requested location or the server default location.","summary":"Get location"}},"/api/location/reload":{"post":{"tags":["location"],"operationId":"location.reload","parameters":[],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.","summary":"Reload configuration"}},"/api/agent":{"get":{"tags":["agent"],"operationId":"agent.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Agent.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve currently registered agents.","summary":"List agents"}},"/api/agent/{agentID}":{"get":{"tags":["agent"],"operationId":"agent.get","parameters":[{"name":"agentID","in":"path","schema":{"$ref":"#/components/schemas/Agent.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Agent.Info"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"AgentNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AgentNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Retrieve a single currently registered agent.","summary":"Get agent"}},"/api/plugin":{"get":{"tags":["plugin"],"operationId":"plugin.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Plugin.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve enabled server plugins and their current status.","summary":"List plugins"}},"/api/plugin/check":{"post":{"tags":["plugin"],"operationId":"plugin.check","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Plugin.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Check one or all package plugins for available updates.","summary":"Check plugin updates","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"target":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false}}},"required":true}}},"/api/plugin/update":{"post":{"tags":["plugin"],"operationId":"plugin.update","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.","summary":"Update plugins","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"string"}}},"required":["targets"],"additionalProperties":false}}},"required":true}}},"/api/session":{"get":{"tags":["session"],"operationId":"session.list","parameters":[{"name":"limit","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Maximum number of sessions to return. Defaults to the newest 50 sessions."},"required":false},{"name":"order","in":"query","schema":{"anyOf":[{"type":"string","enum":["asc","desc"]},{"type":"null"}],"description":"Session order for the first page. Use desc for newest first or asc for oldest first."},"required":false},{"name":"search","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"parentID","in":"query","schema":{"anyOf":[{"anyOf":[{"$ref":"#/components/schemas/Session.ID"},{"type":"string","enum":["null"]}],"description":"Filter by parent session. Use null to return only root sessions."},{"type":"null"}]},"required":false},{"name":"directory","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/AbsolutePath"},{"type":"null"}]},"required":false},{"name":"project","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/Project.ID"},{"type":"null"}]},"required":false},{"name":"subpath","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/RelativePath"},{"type":"null"}]},"required":false},{"name":"cursor","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/SessionsCursor"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SessionsResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionsResponse"}}}},"400":{"description":"InvalidCursorError | InvalidRequestError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/InvalidCursorErrorEncoded"},{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}]}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.","summary":"List sessions"},"post":{"tags":["session"],"operationId":"session.create","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Create a session at the requested location. A parentID creates a linked child session at its parent's location.","summary":"Create session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"$ref":"#/components/schemas/Session.ID"},{"type":"null"}]},"parentID":{"anyOf":[{"$ref":"#/components/schemas/Session.ID"},{"type":"null"}]},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"agent":{"anyOf":[{"$ref":"#/components/schemas/Agent.ID"},{"type":"null"}]},"model":{"anyOf":[{"$ref":"#/components/schemas/Model.Ref"},{"type":"null"}]},"location":{"anyOf":[{"$ref":"#/components/schemas/Location.PublicRef"},{"type":"null"}]},"metadata":{"anyOf":[{"$ref":"#/components/schemas/Session.Metadata"},{"type":"null"}]},"permissions":{"anyOf":[{"$ref":"#/components/schemas/Permission.Ruleset"},{"type":"null"}]}},"additionalProperties":false}}},"required":true}}},"/api/experimental/session/stats":{"get":{"tags":["session"],"operationId":"experimental.session.stats","parameters":[{"name":"from","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"to","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"project","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/Project.ID"},{"type":"null"}]},"required":false},{"name":"timezone","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"tools","in":"query","schema":{"anyOf":[{"type":"string","enum":["none","summary","detail"]},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/SessionStats.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Aggregate local session activity, usage, and tool reliability for a time range.","summary":"Get session statistics"}},"/api/experimental/session/import":{"post":{"tags":["session"],"operationId":"experimental.session.import","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}}},"description":"Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.","summary":"Import session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"info":{"$ref":"#/components/schemas/Session.Info"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Session.Message.Info"}},"location":{"anyOf":[{"$ref":"#/components/schemas/Location.PublicRef"},{"type":"null"}]}},"required":["info","messages"],"additionalProperties":false}}},"required":true}}},"/api/experimental/session/{sessionID}/export":{"get":{"tags":["session"],"operationId":"experimental.session.export","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"sanitize","in":"query","schema":{"anyOf":[{"type":"string","enum":["true","false"]},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/SessionTransfer.Data"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}},"500":{"description":"UnknownError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnknownErrorEncoded"}}}}},"description":"Export a complete projected session transcript.","summary":"Export session"}},"/api/session/active":{"get":{"tags":["session"],"operationId":"session.active","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","patternProperties":{"^ses":{"$ref":"#/components/schemas/SessionActive"}},"additionalProperties":false}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.","summary":"List active sessions"}},"/api/session/{sessionID}":{"get":{"tags":["session"],"operationId":"session.get","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Retrieve a session by ID.","summary":"Get session"},"delete":{"tags":["session"],"operationId":"session.remove","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Delete a session and its child sessions.","summary":"Delete session"},"patch":{"tags":["session"],"operationId":"session.update","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Update mutable session properties.","summary":"Update session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"metadata":{"anyOf":[{"$ref":"#/components/schemas/Session.Metadata"},{"type":"null"}]},"permissions":{"anyOf":[{"$ref":"#/components/schemas/Permission.Ruleset"},{"type":"null"}]}},"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/fork":{"post":{"tags":["session"],"operationId":"session.fork","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | MessageNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/MessageNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Create a child session by copying projected history before a message. Omit before to copy the full history.","summary":"Fork session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"before":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ID"},{"type":"null"}]}},"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/agent":{"post":{"tags":["session"],"operationId":"session.switchAgent","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Switch the agent used by subsequent provider turns.","summary":"Switch session agent","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agent":{"$ref":"#/components/schemas/Agent.ID"}},"required":["agent"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/model":{"post":{"tags":["session"],"operationId":"session.switchModel","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Switch the model used by subsequent provider turns.","summary":"Switch session model","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"model":{"$ref":"#/components/schemas/Model.Ref"}},"required":["model"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/move":{"post":{"tags":["session"],"operationId":"session.move","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Move a session to another project directory at the requested delivery boundary.","summary":"Move session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"directory":{"$ref":"#/components/schemas/AbsolutePath"},"delivery":{"anyOf":[{"$ref":"#/components/schemas/Session.Inbox.Delivery"},{"type":"null"}]}},"required":["directory"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/prompt":{"post":{"tags":["session"],"operationId":"session.prompt","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Inbox.User"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}}},"description":"Durably admit one session input and schedule agent-loop execution unless resume is false.","summary":"Send message","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ID"},{"type":"null"}]},"text":{"type":"string"},"files":{"type":"array","items":{"$ref":"#/components/schemas/PromptInput.FileAttachment"}},"agents":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.AgentAttachment"}},"skills":{"type":"array","items":{"$ref":"#/components/schemas/PromptInput.SkillAttachment"}},"metadata":{"type":"object"},"delivery":{"anyOf":[{"$ref":"#/components/schemas/Session.Inbox.Delivery"},{"type":"null"}]},"resume":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"required":["text"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/command":{"post":{"tags":["session"],"operationId":"session.command","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | CommandNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/CommandNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"500":{"description":"CommandExecutionError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandExecutionErrorEncoded"}}}}},"description":"Execute a slash command callback immediately.","summary":"Run command","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"text":{"type":"string"},"files":{"type":"array","items":{"$ref":"#/components/schemas/PromptInput.FileAttachment"}},"agents":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.AgentAttachment"}},"skills":{"type":"array","items":{"$ref":"#/components/schemas/PromptInput.SkillAttachment"}},"delivery":{"anyOf":[{"$ref":"#/components/schemas/Session.Inbox.Delivery"},{"type":"null"}]}},"required":["name","text"],"additionalProperties":false}}},"required":true}}},"/api/experimental/session/{sessionID}/skill":{"post":{"tags":["session"],"operationId":"experimental.session.skill","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | SkillNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/SkillNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Activate a skill for a session by appending a skill message and resuming execution.","summary":"Activate skill","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Skill.ID"},"resume":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"required":["id"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/synthetic":{"post":{"tags":["session"],"operationId":"session.synthetic","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Inbox.Synthetic"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}}},"description":"Durably admit synthetic session input and schedule execution unless resume is false.","summary":"Add synthetic message","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ID"},{"type":"null"}]},"text":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"metadata":{"type":"object"},"delivery":{"anyOf":[{"$ref":"#/components/schemas/Session.Inbox.Delivery"},{"type":"null"}]},"resume":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"required":["text"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/shell":{"post":{"tags":["session"],"operationId":"session.shell","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.","summary":"Run shell command","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ID"},{"type":"null"}]},"command":{"type":"string"}},"required":["command"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/compact":{"post":{"tags":["session"],"operationId":"session.compact","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Inbox.Compaction"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}}},"description":"Durably admit a session compaction request. Steers by default: it runs at the next step boundary instead of waiting behind queued prompts.","summary":"Compact session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ID"},{"type":"null"}]},"delivery":{"anyOf":[{"$ref":"#/components/schemas/Session.Inbox.Delivery"},{"type":"null"}]}},"additionalProperties":false}}},"required":true}}},"/api/experimental/session/{sessionID}/wait":{"post":{"tags":["session"],"operationId":"experimental.session.wait","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Wait for a session agent loop to become idle.","summary":"Wait for session"}},"/api/session/{sessionID}/revert/stage":{"post":{"tags":["session"],"operationId":"session.revert.stage","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Revert"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"MessageNotFoundError | SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/MessageNotFoundErrorEncoded"},{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"SessionBusyError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionBusyErrorEncoded"}}}},"500":{"description":"UnknownError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnknownErrorEncoded"}}}}},"description":"Stage or move a reversible session boundary and optionally apply its file changes.","summary":"Stage session revert","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"messageID":{"$ref":"#/components/schemas/Session.Message.ID"},"files":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"required":["messageID"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/revert":{"delete":{"tags":["session"],"operationId":"session.revert.clear","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"SessionBusyError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionBusyErrorEncoded"}}}},"500":{"description":"UnknownError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnknownErrorEncoded"}}}}},"summary":"Clear staged revert"}},"/api/session/{sessionID}/revert/commit":{"post":{"tags":["session"],"operationId":"session.revert.commit","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"SessionBusyError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionBusyErrorEncoded"}}}}},"summary":"Commit staged revert"}},"/api/session/{sessionID}/context":{"get":{"tags":["session"],"operationId":"session.context","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Session.Message.Info"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}},"500":{"description":"UnknownError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnknownErrorEncoded"}}}}},"description":"Retrieve the active context messages for a session (all messages after the last compaction).","summary":"Get session context"}},"/api/session/{sessionID}/diff":{"get":{"tags":["session"],"operationId":"session.diff","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"from","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ID"},{"type":"null"}],"description":"User message whose turn to diff. Defaults to the turn of the newest user message."},"required":false},{"name":"to","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ID"},{"type":"null"}],"description":"Later user message whose turn ends the range. Defaults to the turn of `from` alone."},"required":false},{"name":"context","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Unchanged lines around each hunk. Omit for full-file patches."},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/FileDiff.Info"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"MessageNotFoundError | SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/MessageNotFoundErrorEncoded"},{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"500":{"description":"UnknownError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnknownErrorEncoded"}}}}},"description":"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.","summary":"Diff session turns"}},"/api/session/{sessionID}/inbox":{"get":{"tags":["session"],"operationId":"session.inbox.list","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Session.Inbox.Info"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.","summary":"List session inbox"}},"/api/session/{sessionID}/inbox/{inboxID}":{"delete":{"tags":["session"],"operationId":"session.inbox.cancel","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"inboxID","in":"path","schema":{"$ref":"#/components/schemas/Session.Message.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Cancel an inbox item that has not yet been delivered. Unavailable items are a no-op.","summary":"Cancel inbox input"},"patch":{"tags":["session"],"operationId":"session.inbox.update","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"inboxID","in":"path","schema":{"$ref":"#/components/schemas/Session.Message.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}}},"description":"Change a pending inbox item's delivery mode. Steering wakes session execution.","summary":"Update inbox item","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"delivery":{"$ref":"#/components/schemas/Session.Inbox.Delivery"}},"required":["delivery"],"additionalProperties":false}}},"required":true}}},"/api/experimental/session/{sessionID}/instructions/entries":{"get":{"tags":["session"],"operationId":"experimental.session.instructions.entry.list","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InstructionEntry.Info"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"List API-managed instruction entries attached to the session.","summary":"List instruction entries"}},"/api/experimental/session/{sessionID}/instructions/entries/{key}":{"put":{"tags":["session"],"operationId":"experimental.session.instructions.entry.put","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"key","in":"path","schema":{"$ref":"#/components/schemas/InstructionEntry.Key"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"413":{"description":"InstructionEntryValueTooLargeError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstructionEntryValueTooLargeErrorEncoded"}}}}},"description":"Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.","summary":"Put instruction entry","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"value":{}},"required":["value"],"additionalProperties":false}}},"required":true}},"delete":{"tags":["session"],"operationId":"experimental.session.instructions.entry.remove","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"key","in":"path","schema":{"$ref":"#/components/schemas/InstructionEntry.Key"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Remove one instruction entry; the removal is announced to the model at the next step boundary.","summary":"Remove instruction entry"}},"/api/session/{sessionID}/generate":{"post":{"tags":["session"],"operationId":"session.generate","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"SessionGenerateResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionGenerateResponse"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Generate transient text from the current session context without mutating session history.","summary":"Generate text from session context","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"],"additionalProperties":false}}},"required":true}}},"/api/experimental/session/{sessionID}/log":{"get":{"tags":["session"],"operationId":"session.log","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"after","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/Event.SeqEncoded"},{"type":"null"}]},"required":false},{"name":"follow","in":"query","schema":{"anyOf":[{"type":"string","enum":["true","false"]},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"text/event-stream":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}]},"event":{"type":"string"},"data":{"$ref":"#/components/schemas/SessionLogItemEncoded"}},"required":["event","data"],"additionalProperties":false},"x-effect-stream":{"encoding":"sse","causeSchema":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"_tag":{"type":"string","enum":["Fail"]},"error":{"not":{}}},"required":["_tag","error"],"additionalProperties":false},{"type":"object","properties":{"_tag":{"type":"string","enum":["Die"]},"defect":{}},"required":["_tag","defect"],"additionalProperties":false},{"type":"object","properties":{"_tag":{"type":"string","enum":["Interrupt"]},"fiberId":{"anyOf":[{"type":"number"},{"type":"null"}]}},"required":["_tag","fiberId"],"additionalProperties":false}]}},"errorSchema":{"not":{}},"failureEvent":"effect/http-api/stream/failure"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.","summary":"Read the session log"}},"/api/session/{sessionID}/interrupt":{"post":{"tags":["session"],"operationId":"session.interrupt","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"resume","in":"query","schema":{"anyOf":[{"type":"string","enum":["true","false"]},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"SessionInterruptResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInterruptResponse"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Interrupt active execution owned by this OpenCode process. Returns interrupted=true when an active execution was interrupted and false for the idle no-op. When resume=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.","summary":"Interrupt session execution"}},"/api/session/{sessionID}/background":{"post":{"tags":["session"],"operationId":"session.background","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.","summary":"Background blocking session tools"}},"/api/session/{sessionID}/message/{messageID}":{"get":{"tags":["session"],"operationId":"session.message.get","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"messageID","in":"path","schema":{"$ref":"#/components/schemas/Session.Message.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Session.Message.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | MessageNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/MessageNotFoundErrorEncoded"}]}}}}},"description":"Retrieve one projected message owned by the Session.","summary":"Get session message"}},"/api/session/{sessionID}/form":{"get":{"tags":["session"],"operationId":"session.form.list","parameters":[{"name":"sessionID","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Form.Info"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Retrieve pending forms for a session.","summary":"List session forms"},"post":{"tags":["session"],"operationId":"session.form.create","parameters":[{"name":"sessionID","in":"path","schema":{"type":"string"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Form.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}}},"description":"Create a form for a session.","summary":"Create session form","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Form.CreatePayload"}}},"required":true}}},"/api/session/{sessionID}/form/{formID}":{"get":{"tags":["session"],"operationId":"session.form.get","parameters":[{"name":"sessionID","in":"path","schema":{"type":"string"},"required":true},{"name":"formID","in":"path","schema":{"$ref":"#/components/schemas/Form.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Form.Detail"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | FormNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/FormNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Retrieve a form and its current state for a session.","summary":"Get session form"},"delete":{"tags":["session"],"operationId":"session.form.cancel","parameters":[{"name":"sessionID","in":"path","schema":{"type":"string"},"required":true},{"name":"formID","in":"path","schema":{"$ref":"#/components/schemas/Form.ID"},"required":true},{"name":"message","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | FormNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/FormNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"FormAlreadySettledError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormAlreadySettledErrorEncoded"}}}}},"description":"Cancel a pending form, optionally telling the asker why it was not answered.","summary":"Cancel form"}},"/api/session/{sessionID}/form/{formID}/reply":{"post":{"tags":["session"],"operationId":"session.form.reply","parameters":[{"name":"sessionID","in":"path","schema":{"type":"string"},"required":true},{"name":"formID","in":"path","schema":{"$ref":"#/components/schemas/Form.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"FormInvalidAnswerError | InvalidRequestError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/FormInvalidAnswerErrorEncoded"},{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}]}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | FormNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/FormNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"409":{"description":"FormAlreadySettledError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormAlreadySettledErrorEncoded"}}}}},"description":"Submit an answer to a pending form.","summary":"Reply to form","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Form.Reply"}}},"required":true}}},"/api/session/{sessionID}/environment":{"put":{"tags":["session"],"operationId":"session.environment","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Replace the process environment used by local shell commands for this session.","summary":"Set session environment","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"variables":{"type":"object","additionalProperties":{"type":"string"}}},"required":["variables"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/view":{"post":{"tags":["session"],"operationId":"session.view","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}}},"description":"Mark the idle transition observed by the viewer as viewed.","summary":"View session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"idle":{"type":"number"}},"required":["idle"],"additionalProperties":false}}},"required":true}}},"/api/session/{sessionID}/message":{"get":{"tags":["session"],"operationId":"session.message.list","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"limit","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Maximum number of messages to return. When omitted, the endpoint returns its default page size."},"required":false},{"name":"order","in":"query","schema":{"anyOf":[{"type":"string","enum":["asc","desc"]},{"type":"null"}],"description":"Message order for the first page. Use desc for newest first or asc for oldest first."},"required":false},{"name":"cursor","in":"query","schema":{"anyOf":[{"type":"string","description":"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order."},{"type":"null"}]},"required":false},{"name":"type","in":"query","schema":{"anyOf":[{"type":"string","enum":["agent-switched","model-switched","location-switched","user","synthetic","system","skill","shell","assistant","compaction"]},{"type":"null"}],"description":"Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."},"required":false}],"security":[],"responses":{"200":{"description":"SessionMessagesResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionMessagesResponse"}}}},"400":{"description":"InvalidCursorError | InvalidRequestError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/InvalidCursorErrorEncoded"},{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}]}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"}}}},"500":{"description":"UnknownError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnknownErrorEncoded"}}}}},"description":"Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.","summary":"Get session messages"}},"/api/model":{"get":{"tags":["model"],"operationId":"model.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Model.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Retrieve the current snapshot of available models ordered by release date. The snapshot may precede initial plugin settlement.","summary":"List models"}},"/api/model/default":{"get":{"tags":["model"],"operationId":"model.default","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"anyOf":[{"$ref":"#/components/schemas/Model.Info"},{"type":"null"}]}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Retrieve the model used when a session has no explicit model selection.","summary":"Get default model"}},"/api/experimental/generate":{"post":{"tags":["generate"],"operationId":"experimental.generate.text","parameters":[],"security":[],"responses":{"200":{"description":"GenerateTextResponse","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateTextResponse"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Run one stateless model generation using the server's base configuration and return the assistant text. Uses the base configuration's default model when none is specified.","summary":"Generate text","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"},"model":{"anyOf":[{"$ref":"#/components/schemas/Model.Ref"},{"type":"null"}]}},"required":["prompt"],"additionalProperties":false}}},"required":true}}},"/api/provider":{"get":{"tags":["provider"],"operationId":"provider.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Provider.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Retrieve active AI providers so clients can show provider availability and configuration.","summary":"List providers"}},"/api/provider/{providerID}":{"get":{"tags":["provider"],"operationId":"provider.get","parameters":[{"name":"providerID","in":"path","schema":{"$ref":"#/components/schemas/Provider.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Provider.Info"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ProviderNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProviderNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Retrieve a single AI provider so clients can inspect its availability and endpoint settings.","summary":"Get provider"}},"/api/integration":{"get":{"tags":["integration"],"operationId":"integration.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Integration.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve available integrations and their authentication methods.","summary":"List integrations"}},"/api/integration/{integrationID}":{"get":{"tags":["integration"],"operationId":"integration.get","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Integration.Info"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"IntegrationNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Retrieve one integration and its authentication methods.","summary":"Get integration"}},"/api/experimental/integration/wellknown":{"post":{"tags":["integration"],"operationId":"experimental.integration.wellknown.add","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Discover and persist an experimental wellknown integration source.","summary":"Add wellknown integration","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"],"additionalProperties":false}}},"required":true}}},"/api/integration/{integrationID}/connect/key":{"post":{"tags":["integration"],"operationId":"integration.connect.key","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"IntegrationNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Run a key authentication method and store the resulting credential.","summary":"Connect with key","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string"},"answer":{"anyOf":[{"$ref":"#/components/schemas/Form.Answer"},{"type":"null"}]},"label":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["key"],"additionalProperties":false}}},"required":true}}},"/api/integration/{integrationID}/connect/external":{"post":{"tags":["integration"],"operationId":"integration.connect.external","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"IntegrationNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Run an external authentication method and store a reference to its credential source.","summary":"Connect with external credentials","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"methodID":{"$ref":"#/components/schemas/Integration.MethodID"},"answer":{"anyOf":[{"$ref":"#/components/schemas/Form.Answer"},{"type":"null"}]},"label":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["methodID"],"additionalProperties":false}}},"required":true}}},"/api/integration/{integrationID}/connect/oauth":{"post":{"tags":["integration"],"operationId":"integration.oauth.connect","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Integration.AttemptEncoded"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Start an OAuth attempt and return the authorization details.","summary":"Begin OAuth connection","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"methodID":{"$ref":"#/components/schemas/Integration.MethodID"},"answer":{"anyOf":[{"$ref":"#/components/schemas/Form.Answer"},{"type":"null"}]},"label":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["methodID"],"additionalProperties":false}}},"required":true}}},"/api/integration/{integrationID}/connect/oauth/{attemptID}":{"get":{"tags":["integration"],"operationId":"integration.oauth.status","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"attemptID","in":"path","schema":{"$ref":"#/components/schemas/Integration.AttemptID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Integration.AttemptStatus"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"IntegrationNotFoundError | IntegrationAttemptNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationNotFoundErrorEncoded"},{"$ref":"#/components/schemas/IntegrationAttemptNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Poll the current status of an OAuth attempt.","summary":"Get OAuth attempt status"},"delete":{"tags":["integration"],"operationId":"integration.oauth.cancel","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"attemptID","in":"path","schema":{"$ref":"#/components/schemas/Integration.AttemptID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Cancel an OAuth attempt and release its resources.","summary":"Cancel OAuth connection"}},"/api/integration/{integrationID}/connect/oauth/{attemptID}/complete":{"post":{"tags":["integration"],"operationId":"integration.oauth.complete","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"attemptID","in":"path","schema":{"$ref":"#/components/schemas/Integration.AttemptID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"IntegrationNotFoundError | IntegrationAttemptNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationNotFoundErrorEncoded"},{"$ref":"#/components/schemas/IntegrationAttemptNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Complete a code-based OAuth attempt and store the resulting credential.","summary":"Complete OAuth connection","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"code":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false}}},"required":true}}},"/api/integration/{integrationID}/connect/command":{"post":{"tags":["integration"],"operationId":"integration.command.connect","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Integration.CommandAttempt"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"IntegrationNotFoundError | IntegrationMethodNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationNotFoundErrorEncoded"},{"$ref":"#/components/schemas/IntegrationMethodNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Start a command authentication attempt.","summary":"Begin command connection","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"methodID":{"$ref":"#/components/schemas/Integration.MethodID"},"label":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["methodID"],"additionalProperties":false}}},"required":true}}},"/api/integration/{integrationID}/connect/command/{attemptID}":{"get":{"tags":["integration"],"operationId":"integration.command.status","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"attemptID","in":"path","schema":{"$ref":"#/components/schemas/Integration.AttemptID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Integration.CommandAttemptStatus"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"IntegrationNotFoundError | IntegrationAttemptNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/IntegrationNotFoundErrorEncoded"},{"$ref":"#/components/schemas/IntegrationAttemptNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Poll the current status and output of a command authentication attempt.","summary":"Get command attempt status"},"delete":{"tags":["integration"],"operationId":"integration.command.cancel","parameters":[{"name":"integrationID","in":"path","schema":{"$ref":"#/components/schemas/Integration.ID"},"required":true},{"name":"attemptID","in":"path","schema":{"$ref":"#/components/schemas/Integration.AttemptID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Cancel a command authentication attempt and terminate its process.","summary":"Cancel command connection"}},"/api/mcp":{"get":{"tags":["mcp"],"operationId":"mcp.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Mcp.Server"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve configured MCP servers and their connection status.","summary":"List MCP servers"}},"/api/experimental/mcp/{server}":{"put":{"tags":["mcp"],"operationId":"experimental.mcp.add","parameters":[{"name":"server","in":"path","schema":{"type":"string"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Add an MCP server at runtime or replace an existing one, connecting it immediately.","summary":"Add MCP server","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"config":{"anyOf":[{"$ref":"#/components/schemas/Mcp.LocalConfigEncoded"},{"$ref":"#/components/schemas/Mcp.RemoteConfigEncoded"}]}},"required":["config"],"additionalProperties":false}}},"required":true}},"delete":{"tags":["mcp"],"operationId":"experimental.mcp.remove","parameters":[{"name":"server","in":"path","schema":{"type":"string"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"McpServerNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/McpServerNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Stop an MCP server and remove it from the runtime set until restart.","summary":"Remove MCP server"}},"/api/experimental/mcp/{server}/connect":{"post":{"tags":["mcp"],"operationId":"experimental.mcp.connect","parameters":[{"name":"server","in":"path","schema":{"type":"string"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"McpServerNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/McpServerNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Connect an MCP server at runtime, overriding a disabled configuration until restart.","summary":"Connect MCP server"}},"/api/experimental/mcp/{server}/disconnect":{"post":{"tags":["mcp"],"operationId":"experimental.mcp.disconnect","parameters":[{"name":"server","in":"path","schema":{"type":"string"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"McpServerNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/McpServerNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Disconnect an MCP server at runtime, removing its tools until reconnected.","summary":"Disconnect MCP server"}},"/api/mcp/resource":{"get":{"tags":["mcp"],"operationId":"mcp.resource.catalog","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Mcp.ResourceCatalog"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve resources and resource templates from connected MCP servers.","summary":"List MCP resources"}},"/api/credential":{"get":{"tags":["credential"],"operationId":"credential.list","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Credential.Entry"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"List every stored integration credential, including its secret value.","summary":"List credentials"},"post":{"tags":["credential"],"operationId":"credential.create","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Credential.Entry"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}}},"description":"Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.","summary":"Create credential","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credential.CreateInput"}}},"required":true}}},"/api/credential/{credentialID}":{"patch":{"tags":["credential"],"operationId":"credential.update","parameters":[{"name":"credentialID","in":"path","schema":{"$ref":"#/components/schemas/Credential.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Update a stored credential label.","summary":"Update credential","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string"}},"required":["label"],"additionalProperties":false}}},"required":true}},"delete":{"tags":["credential"],"operationId":"credential.remove","parameters":[{"name":"credentialID","in":"path","schema":{"$ref":"#/components/schemas/Credential.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Remove a stored integration credential.","summary":"Remove credential"}},"/api/credential/{credentialID}/activate":{"post":{"tags":["credential"],"operationId":"credential.activate","parameters":[{"name":"credentialID","in":"path","schema":{"$ref":"#/components/schemas/Credential.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Activate a stored integration credential.","summary":"Activate credential"}},"/api/project":{"get":{"tags":["project"],"operationId":"project.list","parameters":[{"name":"archived","in":"query","schema":{"anyOf":[{"type":"string","enum":["true","false"]},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"List known projects. Archived projects are excluded unless archived=true.","summary":"List projects"}},"/api/project/{projectID}":{"patch":{"tags":["project"],"operationId":"project.update","parameters":[{"name":"projectID","in":"path","schema":{"$ref":"#/components/schemas/Project.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ProjectNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ProjectNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Update the project canonical directory, display metadata, and workspace commands.","summary":"Update project","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"canonical":{"$ref":"#/components/schemas/AbsolutePath"},"name":{"type":"string"},"icon":{"$ref":"#/components/schemas/Project.Icon"},"commands":{"$ref":"#/components/schemas/Project.Commands"}},"additionalProperties":false}}},"required":true}}},"/api/form":{"get":{"tags":["form"],"operationId":"form.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Form.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve pending forms for a location.","summary":"List pending forms"}},"/api/permission/request":{"get":{"tags":["permission"],"operationId":"permission.request.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Permission.Request"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve pending permission requests for a location.","summary":"List pending permission requests"}},"/api/permission/saved":{"get":{"tags":["permission"],"operationId":"permission.saved.list","parameters":[{"name":"projectID","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/Project.ID"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PermissionSaved.Info"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve saved permissions, optionally filtered by project.","summary":"List saved permissions"}},"/api/permission/saved/{id}":{"delete":{"tags":["permission"],"operationId":"permission.saved.remove","parameters":[{"name":"id","in":"path","schema":{"$ref":"#/components/schemas/PermissionSaved.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Remove a saved permission by ID.","summary":"Remove saved permission"}},"/api/session/{sessionID}/permission":{"post":{"tags":["permission"],"operationId":"session.permission.create","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Permission.ID"},"effect":{"$ref":"#/components/schemas/Permission.Effect"}},"required":["id","effect"],"additionalProperties":false}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Evaluate and, when approval is required, create a permission request for a session.","summary":"Create permission request","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"$ref":"#/components/schemas/Permission.ID"},{"type":"null"}]},"action":{"type":"string"},"resources":{"type":"array","items":{"type":"string"}},"save":{"type":"array","items":{"type":"string"}},"metadata":{"type":"object"},"source":{"$ref":"#/components/schemas/Permission.Source"},"agent":{"anyOf":[{"$ref":"#/components/schemas/Agent.ID"},{"type":"null"}]}},"required":["action","resources"],"additionalProperties":false}}},"required":true}},"get":{"tags":["permission"],"operationId":"session.permission.list","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Permission.Request"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Retrieve pending permission requests owned by a session.","summary":"List session permission requests"}},"/api/session/{sessionID}/permission/{requestID}":{"get":{"tags":["permission"],"operationId":"session.permission.get","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"requestID","in":"path","schema":{"$ref":"#/components/schemas/Permission.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Permission.Request"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | PermissionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/PermissionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Retrieve a pending permission request owned by a session.","summary":"Get permission request"}},"/api/session/{sessionID}/permission/{requestID}/reply":{"post":{"tags":["permission"],"operationId":"session.permission.reply","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"requestID","in":"path","schema":{"$ref":"#/components/schemas/Permission.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"SessionNotFoundError | PermissionNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SessionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/PermissionNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Respond to a pending permission request owned by a session.","summary":"Reply to pending permission request","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"decision":{"$ref":"#/components/schemas/Permission.Reply"},"message":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["decision"],"additionalProperties":false}}},"required":true}}},"/api/fs/read/*":{"get":{"tags":["filesystem"],"operationId":"fs.read","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"FileNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/FileNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Stream one file relative to the requested location with ETag, Last-Modified, and single-range HTTP Range support (206 Partial Content, 416 Range Not Satisfiable, and 304 Not Modified; multi-range and malformed Range headers fall back to 200).","summary":"Read file"}},"/api/fs/list":{"get":{"tags":["filesystem"],"operationId":"fs.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true},{"name":"path","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"An absolute path or a path relative to the requested location. Defaults to the location directory."},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FileSystem.Entry"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"FileNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/FileNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.","summary":"List directory"}},"/api/fs/find":{"get":{"tags":["filesystem"],"operationId":"fs.find","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true},{"name":"query","in":"query","schema":{"type":"string"},"required":true},{"name":"type","in":"query","schema":{"type":"string","enum":["file","directory"]},"required":false},{"name":"limit","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FileSystem.Entry"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Find recursively ranked filesystem entries relative to the requested location.","summary":"Find files"}},"/api/experimental/fs/write":{"post":{"tags":["filesystem"],"operationId":"experimental.fs.write","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true},{"name":"path","in":"query","schema":{"type":"string","description":"An absolute path or a path relative to the requested location. Missing parent directories are created."},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/FileSystem.Write"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Write the raw request body to an absolute path or a path relative to the requested location, creating parent directories, and return the resolved absolute path. Unlike read, the target is not confined to the location. Experimental: may change without compatibility guarantees.","summary":"Write file","requestBody":{"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}},"required":true}}},"/api/command":{"get":{"tags":["command"],"operationId":"command.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Command.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve currently registered commands.","summary":"List commands"}},"/api/skill":{"get":{"tags":["skill"],"operationId":"skill.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Skill.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Retrieve currently registered skills.","summary":"List skills"}},"/api/rpc/{rpcID}/{method}":{"post":{"tags":["rpc"],"operationId":"rpc.call","parameters":[{"name":"rpcID","in":"path","schema":{"type":"string"},"required":true},{"name":"method","in":"path","schema":{"type":"string"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Rpc.Output","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Rpc.Output"}}}},"400":{"description":"RpcError | InvalidRequestError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/RpcErrorEncoded"},{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}]}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"500":{"description":"RpcInternalError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RpcInternalErrorEncoded"}}}}},"description":"Dispatch a method to the currently registered RPC at the requested location.","summary":"Call a plugin RPC","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Rpc.Input"}}},"required":true}}},"/api/event":{"get":{"tags":["event"],"operationId":"event.subscribe","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"text/event-stream":{"schema":{"type":"object","properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}]},"event":{"type":"string"},"data":{"$ref":"#/components/schemas/V2EventEncoded"}},"required":["event","data"],"additionalProperties":false},"x-effect-stream":{"encoding":"sse","causeSchema":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"_tag":{"type":"string","enum":["Fail"]},"error":{"not":{}}},"required":["_tag","error"],"additionalProperties":false},{"type":"object","properties":{"_tag":{"type":"string","enum":["Die"]},"defect":{}},"required":["_tag","defect"],"additionalProperties":false},{"type":"object","properties":{"_tag":{"type":"string","enum":["Interrupt"]},"fiberId":{"anyOf":[{"type":"number"},{"type":"null"}]}},"required":["_tag","fiberId"],"additionalProperties":false}]}},"errorSchema":{"not":{}},"failureEvent":"effect/http-api/stream/failure"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.","summary":"Subscribe to events"}},"/api/pty":{"get":{"tags":["pty"],"operationId":"pty.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Pty"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"List PTY sessions for a location, including exited sessions retained until removal.","summary":"List PTY sessions"},"post":{"tags":["pty"],"operationId":"pty.create","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Pty"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Create a pseudo-terminal session for a location.","summary":"Create PTY session","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","additionalProperties":{"type":"string"}}},"additionalProperties":false}}},"required":true}}},"/api/pty/{ptyID}":{"get":{"tags":["pty"],"operationId":"pty.get","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Pty"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"PtyNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Get one PTY session, including its exit code once exited.","summary":"Get PTY session"},"put":{"tags":["pty"],"operationId":"pty.update","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Pty"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"PtyNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Update the title or viewport size of one PTY session.","summary":"Update PTY session","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"size":{"type":"object","properties":{"rows":{"type":"integer","exclusiveMinimum":0},"cols":{"type":"integer","exclusiveMinimum":0}},"required":["rows","cols"],"additionalProperties":false}},"additionalProperties":false}}},"required":true}},"delete":{"tags":["pty"],"operationId":"pty.remove","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"PtyNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Terminate and remove one PTY session.","summary":"Remove PTY session"}},"/api/pty/{ptyID}/connect-token":{"post":{"tags":["pty"],"operationId":"pty.connect.token","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true},{"name":"x-opencode-ticket","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/PtyTicket.ConnectToken"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"403":{"description":"ForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenErrorEncoded"}}}},"404":{"description":"PtyNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Create a short-lived single-use ticket for opening a PTY WebSocket connection.","summary":"Create PTY WebSocket token"}},"/api/pty/{ptyID}/connect":{"get":{"tags":["pty"],"operationId":"pty.connect","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true},{"in":"query","name":"location[directory]","schema":{"type":"string"}},{"in":"query","name":"cursor","schema":{"type":"string"}},{"in":"query","name":"ticket","schema":{"type":"string"}}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"boolean"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"403":{"description":"ForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenErrorEncoded"}}}},"404":{"description":"PtyNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Establish a WebSocket connection streaming PTY output and accepting terminal input.","summary":"Connect to PTY session","x-websocket":true}},"/api/experimental/session/{sessionID}/terminal/read":{"get":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.read","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true},{"name":"lines","in":"query","schema":{"anyOf":[{"$ref":"#/components/schemas/PersistentPty.ReadLinesEncoded"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"anyOf":[{"$ref":"#/components/schemas/PersistentPty.ReadResult"},{"type":"null"}]}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Read the last physical rows without changing selection or taking control. Omitted lines uses the live terminal height; larger counts include retained history. Blank rows are preserved. Screen dimensions and cursor remain relative to the live screen. Returns null when no current terminal exists. Selection is server-local and resets on restart. Experimental: may change without compatibility guarantees.","summary":"Read the session's most recently controlled terminal"}},"/api/experimental/session/{sessionID}/terminal":{"get":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.list","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PersistentPty.Info"}}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}}},"post":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.create","parameters":[{"name":"sessionID","in":"path","schema":{"$ref":"#/components/schemas/Session.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PersistentPty.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersistentPty.CreateInput"}}},"required":true}}},"/api/experimental/persistent-pty/shutdown":{"post":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.shutdown","parameters":[],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}}}},"/api/experimental/persistent-pty/handoff":{"post":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.handoff","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"handoff":{"anyOf":[{"$ref":"#/components/schemas/PersistentPty.Handoff"},{"type":"null"}]}},"required":["handoff"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}}}},"/api/experimental/persistent-pty/{ptyID}":{"get":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.get","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PersistentPty.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"PtyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}}},"put":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.update","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PersistentPty.Info"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"PtyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersistentPty.UpdateInput"}}},"required":true}},"delete":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.remove","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"PtyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}}}},"/api/experimental/persistent-pty/{ptyID}/snapshot":{"get":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.snapshot","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PersistentPty.Snapshot"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"PtyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}}}},"/api/experimental/persistent-pty/{ptyID}/connect-token":{"post":{"tags":["persistentPty"],"operationId":"server.experimental.persistentPty.connectToken","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true},{"name":"x-opencode-ticket","in":"header","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PtyTicket.ConnectToken"}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"403":{"description":"ForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenErrorEncoded"}}}},"404":{"description":"PtyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}}}},"/api/experimental/persistent-pty/{ptyID}/connect":{"get":{"tags":["persistentPty"],"operationId":"persistentPty.connect","parameters":[{"name":"ptyID","in":"path","schema":{"$ref":"#/components/schemas/Pty.ID"},"required":true},{"in":"query","name":"cursor","schema":{"type":"string"}},{"in":"query","name":"role","schema":{"type":"string"}},{"in":"query","name":"attachment_id","schema":{"type":"string"}},{"in":"query","name":"takeover","schema":{"type":"string"}},{"in":"query","name":"input_protocol","schema":{"type":"string"}},{"in":"query","name":"ticket","schema":{"type":"string"}}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"boolean"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"403":{"description":"ForbiddenError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForbiddenErrorEncoded"}}}},"404":{"description":"PtyNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PtyNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Stream persistent PTY output through the OpenCode server.","summary":"Connect to a persistent PTY","x-websocket":true}},"/api/shell":{"get":{"tags":["shell"],"operationId":"shell.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Shell.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"List currently running shell commands for a location. Exited commands are not included.","summary":"List running shell commands"},"post":{"tags":["shell"],"operationId":"shell.create","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Shell.Info"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.","summary":"Run shell command","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"command":{"type":"string"},"cwd":{"type":"string"},"timeout":{"type":"integer","minimum":0},"metadata":{"type":"object"}},"required":["command"],"additionalProperties":false}}},"required":true}}},"/api/shell/{id}":{"get":{"tags":["shell"],"operationId":"shell.get","parameters":[{"name":"id","in":"path","schema":{"$ref":"#/components/schemas/Shell.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Shell.Info"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ShellNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ShellNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Get one shell command, including its status and exit code once exited.","summary":"Get shell command"},"delete":{"tags":["shell"],"operationId":"shell.remove","parameters":[{"name":"id","in":"path","schema":{"$ref":"#/components/schemas/Shell.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Terminate and remove one shell command and its retained output.","summary":"Remove shell command"}},"/api/shell/{id}/output":{"get":{"tags":["shell"],"operationId":"shell.output","parameters":[{"name":"id","in":"path","schema":{"$ref":"#/components/schemas/Shell.ID"},"required":true},{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true},{"name":"cursor","in":"query","schema":{"type":"string","pattern":"^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$"},"required":false},{"name":"limit","in":"query","schema":{"type":"string","pattern":"^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$"},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"object","properties":{"output":{"type":"string"},"cursor":{"type":"integer","minimum":0},"size":{"type":"integer","minimum":0},"truncated":{"type":"boolean"}},"required":["output","cursor","size","truncated"],"additionalProperties":false}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ShellNotFoundError | LocationNotFoundError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ShellNotFoundErrorEncoded"},{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}]}}}}},"description":"Page through captured combined output by absolute byte cursor.","summary":"Read shell output"}},"/api/reference":{"get":{"tags":["reference"],"operationId":"reference.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Reference.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"List references available in the requested location.","summary":"List references"}},"/api/worktree":{"get":{"tags":["worktree"],"operationId":"worktree.list","parameters":[{"name":"projectID","in":"query","schema":{"$ref":"#/components/schemas/Project.ID"},"required":true}],"security":[],"responses":{"200":{"description":"Worktree.List","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Worktree.List"}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ProjectNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectNotFoundErrorEncoded"}}}}},"description":"Return the project's saved worktree inventory without loading configuration or running discovery.","summary":"List worktrees"},"post":{"tags":["worktree"],"operationId":"worktree.create","parameters":[],"security":[],"responses":{"200":{"description":"Worktree.Info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Worktree.Info"}}}},"400":{"description":"WorktreeError | InvalidRequestError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WorktreeErrorEncoded"},{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}]}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ProjectNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectNotFoundErrorEncoded"}}}}},"description":"Load the project's canonical configuration, create a local worktree using its selected strategy, then run the project's setup script.","summary":"Create worktree","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Worktree.CreateInput"}}},"required":true}},"delete":{"tags":["worktree"],"operationId":"worktree.remove","parameters":[],"security":[],"responses":{"204":{"description":""},"400":{"description":"WorktreeError | InvalidRequestError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WorktreeErrorEncoded"},{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}]}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ProjectNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectNotFoundErrorEncoded"}}}}},"description":"Load the project's canonical configuration and remove a saved worktree using its recorded strategy.","summary":"Remove worktree","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Worktree.RemoveInput"}}},"required":true}}},"/api/worktree/refresh":{"post":{"tags":["worktree"],"operationId":"worktree.refresh","parameters":[],"security":[],"responses":{"204":{"description":""},"400":{"description":"WorktreeError | InvalidRequestError","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/WorktreeErrorEncoded"},{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}]}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"ProjectNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectNotFoundErrorEncoded"}}}}},"description":"Load the project's canonical configuration, discover worktrees across known checkout roots using all available strategies, and reconcile saved state.","summary":"Refresh worktrees","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"projectID":{"$ref":"#/components/schemas/Project.ID"}},"required":["projectID"],"additionalProperties":false}}},"required":true}}},"/api/vcs/init":{"post":{"tags":["vcs"],"operationId":"vcs.init","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true},{"name":"provider","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"409":{"description":"ConflictError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictErrorEncoded"}}}},"501":{"description":"VcsInitNotSupportedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VcsInitNotSupportedErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.","summary":"Initialize VCS repository"}},"/api/vcs":{"get":{"tags":["vcs"],"operationId":"vcs.get","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Vcs.Info"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Get current and default branch information for the requested location.","summary":"VCS info"}},"/api/vcs/base":{"get":{"tags":["vcs"],"operationId":"vcs.base","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"anyOf":[{"$ref":"#/components/schemas/Vcs.Base"},{"type":"null"}]}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Infer a local review base from named branch creation history, or the repository default only when currently on that branch. Returns null before the first commit or when the provider lacks base metadata; ambiguous Git history requires an explicit base on diff requests.","summary":"VCS review base"}},"/api/vcs/status":{"get":{"tags":["vcs"],"operationId":"vcs.status","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Vcs.FileStatus"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"List uncommitted working-copy changes relative to the requested location.","summary":"VCS status"}},"/api/vcs/branch":{"get":{"tags":["vcs"],"operationId":"vcs.branch.list","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true},{"name":"search","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false},{"name":"limit","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/Vcs.BranchList"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"List local and remote branches available at the requested location.","summary":"VCS branches"}},"/api/vcs/diff":{"get":{"tags":["vcs"],"operationId":"vcs.diff","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true},{"name":"mode","in":"query","schema":{"$ref":"#/components/schemas/Vcs.Mode"},"required":true},{"name":"base","in":"query","schema":{"type":"string"},"required":false},{"name":"context","in":"query","schema":{"anyOf":[{"type":"string"},{"type":"null"}]},"required":false}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FileDiff.Info"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Diff HEAD to the working copy (working), the base merge-base to the working copy (branch), or the base merge-base to HEAD (committed). Omitting base preserves repository-default comparison; supplying it overrides the comparison without saving it.","summary":"VCS diff"}},"/api/debug/location":{"get":{"tags":["debug"],"operationId":"debug.location.list","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Location.PublicRef"}}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"List locations currently loaded by the server.","summary":"List loaded locations"},"delete":{"tags":["debug"],"operationId":"debug.location.evict","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Dispose the requested location's cached services so its next use boots them fresh.","summary":"Evict a loaded location"}},"/api/experimental/migration/v1":{"get":{"tags":["migration"],"operationId":"experimental.migration.v1.status","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["required","completed"]}},"required":["status"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["running"]},"progress":{"type":"object","properties":{"label":{"type":"string"},"numerator":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}]},"denominator":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}]}},"required":["label"],"additionalProperties":false}},"required":["status","progress"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"string"}},"required":["status","error"],"additionalProperties":false}]}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}}},"description":"Return the progress of the V1 to V2 session history migration.","summary":"Get V1 migration status"}},"/api/websearch/provider":{"get":{"tags":["websearch"],"operationId":"websearch.providers","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"type":"array","items":{"$ref":"#/components/schemas/WebSearch.Provider"}}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Return the registered web search providers.","summary":"List web search providers"}},"/api/websearch":{"post":{"tags":["websearch"],"operationId":"websearch.query","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"data":{"$ref":"#/components/schemas/WebSearch.ResponseEncoded"}},"required":["location","data"],"additionalProperties":false}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}},"503":{"description":"ServiceUnavailableError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceUnavailableErrorEncoded"}}}}},"description":"Run one web search through the selected provider. Specify a provider to override the configured default.","summary":"Search the web","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"query":{"type":"string"},"providerID":{"$ref":"#/components/schemas/WebSearch.ID"}},"required":["query"],"additionalProperties":false}}},"required":true}}},"/api/config":{"get":{"tags":["config"],"operationId":"config.get","parameters":[{"name":"location","in":"query","schema":{"anyOf":[{"type":"object","properties":{"directory":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]},"required":false,"style":"deepObject","explode":true}],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Config.Entry"}}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Return configuration documents and discovery sources for the requested location, from lowest to highest priority.","summary":"Get configuration"}},"/api/config/shell":{"get":{"tags":["config"],"operationId":"config.shells","parameters":[],"security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ConfigShell.Option"}}}}},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Return shells available to terminal and agent execution.","summary":"List available shells"}},"/api/experimental/config":{"patch":{"tags":["config"],"operationId":"experimental.config.update","parameters":[],"security":[],"responses":{"204":{"description":""},"400":{"description":"InvalidRequestError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidRequestErrorEncoded"}}}},"401":{"description":"UnauthorizedError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnauthorizedErrorEncoded"}}}},"404":{"description":"LocationNotFoundError","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationNotFoundErrorEncoded"}}}}},"description":"Patch supported fields in the highest-precedence global configuration document.","summary":"Update global configuration","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Config.Patch"}}},"required":true}}}},"components":{"schemas":{"InvalidRequestErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["InvalidRequestError"]},"message":{"type":"string"},"kind":{"anyOf":[{"type":"string"},{"type":"null"}]},"field":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["_tag","message"],"additionalProperties":false},"UnauthorizedErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["UnauthorizedError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"LocationNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["LocationNotFoundError"]},"location":{"$ref":"#/components/schemas/Location.PublicRef"},"message":{"type":"string"}},"required":["_tag","location","message"],"additionalProperties":false},"Location.PublicRef":{"type":"object","properties":{"directory":{"$ref":"#/components/schemas/AbsolutePath"}},"required":["directory"],"additionalProperties":false},"AbsolutePath":{"type":"string"},"Config.Patch":{"type":"object","properties":{"shell":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["shell"],"additionalProperties":false},"ConfigShell.Option":{"type":"object","properties":{"path":{"type":"string"},"name":{"type":"string"},"acceptable":{"type":"boolean"}},"required":["path","name","acceptable"],"additionalProperties":false},"Config.Entry":{"anyOf":[{"$ref":"#/components/schemas/Config.DocumentEncoded"},{"$ref":"#/components/schemas/Config.DirectoryEncoded"}]},"Config.DirectoryEncoded":{"type":"object","properties":{"type":{"type":"string","enum":["directory"]},"path":{"$ref":"#/components/schemas/AbsolutePath"}},"required":["type","path"],"additionalProperties":false},"Config.DocumentEncoded":{"type":"object","properties":{"type":{"type":"string","enum":["document"]},"path":{"$ref":"#/components/schemas/AbsolutePath"},"info":{"$ref":"#/components/schemas/Config.InfoEncoded"}},"required":["type","info"],"additionalProperties":false},"Config.InfoEncoded":{"type":"object","properties":{"$schema":{"type":"string"},"shell":{"type":"string"},"model":{"anyOf":[{"type":"string","pattern":"^[^/#]+\\/[^#]+(?:#[^#]+)?$"},{"type":"object","properties":{"providerID":{"type":"string","pattern":"^[^/#]+$"},"model":{"type":"string","pattern":"^[^#]+$"},"variant":{"type":"string","pattern":"^[^#]+$"}},"required":["providerID","model"],"additionalProperties":false}]},"default_agent":{"type":"string"},"update":{"type":"string","enum":["disable","notify","auto"]},"share":{"type":"string","enum":["manual","auto","disabled"]},"enterprise":{"type":"object","properties":{"url":{"type":"string"}},"additionalProperties":false},"username":{"type":"string"},"permissions":{"$ref":"#/components/schemas/Permission.Ruleset"},"policies":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["provider.use","tool.use","integration.use"]},"resource":{"type":"string"},"effect":{"type":"string","enum":["allow","deny"]}},"required":["action","resource","effect"],"additionalProperties":false}},"agents":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Config.AgentEncoded"}},"snapshots":{"type":"boolean"},"watcher":{"type":"object","properties":{"ignore":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"formatter":{"anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Config.Formatter.EntryEncoded"}}]},"lsp":{"anyOf":[{"type":"boolean"},{"type":"object","additionalProperties":{"anyOf":[{"type":"object","properties":{"disabled":{"type":"boolean","enum":[true]}},"required":["disabled"],"additionalProperties":false},{"$ref":"#/components/schemas/Config.LSP.ServerEncoded"}]}}]},"media":{"type":"object","properties":{"image":{"type":"object","properties":{"auto_resize":{"type":"boolean"},"max_width":{"type":"integer","exclusiveMinimum":0},"max_height":{"type":"integer","exclusiveMinimum":0},"max_base64_bytes":{"type":"integer","exclusiveMinimum":0}},"additionalProperties":false}},"additionalProperties":false},"tool_output":{"type":"object","properties":{"max_lines":{"type":"integer","exclusiveMinimum":0},"max_bytes":{"type":"integer","exclusiveMinimum":0}},"additionalProperties":false},"mcp":{"type":"object","properties":{"timeout":{"type":"object","properties":{"startup":{"type":"integer","exclusiveMinimum":0},"catalog":{"type":"integer","exclusiveMinimum":0},"execution":{"type":"integer","exclusiveMinimum":0}},"additionalProperties":false},"servers":{"type":"object","additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/Mcp.LocalConfigEncoded"},{"$ref":"#/components/schemas/Mcp.RemoteConfigEncoded"}]}}},"additionalProperties":false},"compaction":{"type":"object","properties":{"auto":{"type":"boolean"},"keep":{"type":"object","properties":{"tokens":{"type":"integer","minimum":0}},"additionalProperties":false},"buffer":{"type":"integer","minimum":0}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"commands":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Config.CommandEncoded"}},"instructions":{"type":"array","items":{"type":"string"}},"references":{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Config.Reference.GitEncoded"},{"$ref":"#/components/schemas/Config.Reference.LocalEncoded"}]}},"websearch":{"anyOf":[{"type":"boolean","enum":[false]},{"$ref":"#/components/schemas/ConfigWebSearch.InfoEncoded"}]},"plugins":{"type":"array","items":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Config.Plugin.EntryEncoded"}]}},"worktree":{"$ref":"#/components/schemas/Config.Worktree"},"warming":{"anyOf":[{"type":"boolean"},{"$ref":"#/components/schemas/Config.WarmingEncoded"}]},"providers":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Config.ProviderEncoded"}},"experimental":{"type":"object","properties":{"portable_shell_scanner":{"type":"boolean"},"subagent_depth":{"type":"integer","minimum":0},"policies":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string","enum":["provider.use","tool.use","integration.use"]},"resource":{"type":"string"},"effect":{"type":"string","enum":["allow","deny"]}},"required":["action","resource","effect"],"additionalProperties":false}}},"additionalProperties":false}},"additionalProperties":false},"Config.ProviderEncoded":{"type":"object","properties":{"canonical":{"$ref":"#/components/schemas/Provider.ID"},"name":{"type":"string"},"env":{"type":"array","items":{"type":"string"}},"package":{"type":"string"},"compatibility":{"$ref":"#/components/schemas/Provider.Compatibility"},"settings":{"$ref":"#/components/schemas/Config.Provider.Settings"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"},"models":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Config.ModelEncoded"}}},"additionalProperties":false},"Config.ModelEncoded":{"type":"object","properties":{"modelID":{"type":"string"},"family":{"type":"string"},"name":{"type":"string"},"compatibility":{"$ref":"#/components/schemas/Model.Compatibility"},"package":{"type":"string"},"settings":{"$ref":"#/components/schemas/Config.Model.Settings"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"},"capabilities":{"$ref":"#/components/schemas/Config.Model.Capabilities"},"variants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"settings":{"$ref":"#/components/schemas/Config.Model.Settings"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"}},"required":["id"],"additionalProperties":false}},"cost":{"anyOf":[{"$ref":"#/components/schemas/Config.Model.CostEncoded"},{"type":"array","items":{"$ref":"#/components/schemas/Config.Model.CostEncoded"}}]},"disabled":{"type":"boolean"},"limit":{"type":"object","properties":{"context":{"type":"integer"},"input":{"type":"integer"},"output":{"type":"integer"}},"additionalProperties":false}},"additionalProperties":false},"Config.Model.CostEncoded":{"type":"object","properties":{"tier":{"type":"object","properties":{"type":{"type":"string","enum":["context"]},"size":{"type":"integer"}},"required":["type","size"],"additionalProperties":false},"input":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"},"output":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"},"cache":{"type":"object","properties":{"read":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"},"write":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"}},"additionalProperties":false}},"required":["input","output"],"additionalProperties":false},"Money.USDPerMillionTokens":{"type":"number"},"Config.Model.Settings":{"type":"object","properties":{"compaction":{"$ref":"#/components/schemas/Provider.Compaction"},"encoding":{"$ref":"#/components/schemas/Provider.Encoding"}},"allOf":[{"type":"object","additionalProperties":{"anyOf":[{},{"type":"null"}]}}]},"Provider.Encoding":{"type":"string","enum":["zstd","gzip","none"]},"Provider.Compaction":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["summary"]}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["native"]}},"required":["type"],"additionalProperties":false}]},"Config.Model.Capabilities":{"type":"object","properties":{"tools":{"type":"boolean"},"input":{"type":"array","items":{"type":"string"}},"output":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"Model.Compatibility":{"type":"object","properties":{"reasoningField":{"$ref":"#/components/schemas/Model.ReasoningField"},"requireReasoning":{"type":"boolean"},"maxTokensField":{"$ref":"#/components/schemas/Model.MaxTokensField"},"requireFinishReason":{"type":"boolean"},"requireAssistantAfterTool":{"type":"boolean"},"supportsPromptCacheKey":{"type":"boolean"},"cacheFormat":{"$ref":"#/components/schemas/Model.CacheFormat"},"supportsThinkingBlockBinding":{"type":"boolean"},"supportsEffortUpdates":{"type":"boolean"}},"additionalProperties":false},"Model.CacheFormat":{"type":"string","enum":["anthropic","alibaba"]},"Model.MaxTokensField":{"type":"string","enum":["max_completion_tokens","max_tokens"]},"Model.ReasoningField":{"anyOf":[{"type":"string","enum":["reasoning","reasoning_content","reasoning_text"]},{"type":"string"}]},"Config.Provider.Settings":{"type":"object","properties":{"timeout":{"anyOf":[{"type":"number"},{"type":"boolean","enum":[false]}]},"headerTimeout":{"anyOf":[{"type":"number"},{"type":"boolean","enum":[false]}]},"chunkTimeout":{"anyOf":[{"type":"number"},{"type":"boolean","enum":[false]}]},"compaction":{"$ref":"#/components/schemas/Provider.Compaction"},"transport":{"$ref":"#/components/schemas/Provider.Transport"},"encoding":{"$ref":"#/components/schemas/Provider.Encoding"}},"allOf":[{"type":"object","additionalProperties":{"anyOf":[{},{"type":"null"}]}}]},"Provider.Transport":{"type":"string","enum":["http","websocket"]},"Provider.Compatibility":{"type":"object","properties":{"reasoningField":{"$ref":"#/components/schemas/Model.ReasoningField"},"requireReasoning":{"type":"boolean"},"maxTokensField":{"$ref":"#/components/schemas/Model.MaxTokensField"},"requireFinishReason":{"type":"boolean"},"requireAssistantAfterTool":{"type":"boolean"},"supportsPromptCacheKey":{"type":"boolean"},"cacheFormat":{"$ref":"#/components/schemas/Model.CacheFormat"}},"additionalProperties":false},"Provider.ID":{"type":"string"},"Config.WarmingEncoded":{"type":"object","properties":{"prompt":{"type":"string"},"interval":{"type":"string"},"duration":{"type":"string"}},"additionalProperties":false},"Config.Worktree":{"type":"object","properties":{"directory":{"type":"string"}},"required":["directory"],"additionalProperties":false},"Config.Plugin.EntryEncoded":{"type":"object","properties":{"package":{"type":"string"},"options":{"type":"object"}},"required":["package"],"additionalProperties":false},"ConfigWebSearch.InfoEncoded":{"type":"object","properties":{"provider":{"anyOf":[{"type":"string","enum":["random"],"description":"Reuse a randomly selected provider until it is rate limited, then switch to another available provider."},{"$ref":"#/components/schemas/WebSearch.ID"}]}},"required":["provider"],"additionalProperties":false},"WebSearch.ID":{"type":"string"},"Config.Reference.LocalEncoded":{"type":"object","properties":{"path":{"type":"string"},"description":{"type":"string"},"hidden":{"type":"boolean"}},"required":["path"],"additionalProperties":false},"Config.Reference.GitEncoded":{"type":"object","properties":{"repository":{"type":"string"},"branch":{"type":"string"},"description":{"type":"string"},"hidden":{"type":"boolean"}},"required":["repository"],"additionalProperties":false},"Config.CommandEncoded":{"type":"object","properties":{"template":{"type":"string"},"description":{"type":"string"},"agent":{"type":"string"},"model":{"anyOf":[{"type":"string","pattern":"^[^/#]+\\/[^#]+(?:#[^#]+)?$"},{"type":"object","properties":{"providerID":{"type":"string","pattern":"^[^/#]+$"},"model":{"type":"string","pattern":"^[^#]+$"},"variant":{"type":"string","pattern":"^[^#]+$"}},"required":["providerID","model"],"additionalProperties":false}]},"subagent":{"type":"boolean"},"subtask":{"type":"boolean","description":"Deprecated alias for subagent."}},"required":["template"],"additionalProperties":false},"Mcp.RemoteConfigEncoded":{"type":"object","properties":{"type":{"type":"string","enum":["remote"]},"url":{"type":"string"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"oauth":{"anyOf":[{"$ref":"#/components/schemas/Mcp.OAuthConfigEncoded"},{"type":"boolean","enum":[false]}]},"disabled":{"type":"boolean"},"codemode":{"type":"boolean"},"timeout":{"type":"object","properties":{"startup":{"type":"integer","exclusiveMinimum":0},"catalog":{"type":"integer","exclusiveMinimum":0},"execution":{"type":"integer","exclusiveMinimum":0}},"additionalProperties":false},"protocol":{"$ref":"#/components/schemas/Mcp.Protocol"}},"required":["type","url"],"additionalProperties":false},"Mcp.Protocol":{"type":"string","enum":["legacy","auto","2026-07-28"],"description":"MCP protocol negotiation. \"legacy\" (default) opens with the initialize handshake and speaks protocol revisions up to 2025-11-25. \"auto\" probes for the 2026-07-28 revision and falls back to legacy when the server does not support it. \"2026-07-28\" requires that revision and fails otherwise."},"Mcp.OAuthConfigEncoded":{"type":"object","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"scope":{"type":"string"},"callback_port":{"type":"integer","minimum":1,"maximum":65535},"redirect_uri":{"type":"string"},"auth_server_metadata_url":{"type":"string"}},"additionalProperties":false},"Mcp.LocalConfigEncoded":{"type":"object","properties":{"type":{"type":"string","enum":["local"]},"command":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"environment":{"type":"object","additionalProperties":{"type":"string"}},"disabled":{"type":"boolean"},"codemode":{"type":"boolean"},"timeout":{"type":"object","properties":{"startup":{"type":"integer","exclusiveMinimum":0},"catalog":{"type":"integer","exclusiveMinimum":0},"execution":{"type":"integer","exclusiveMinimum":0}},"additionalProperties":false},"protocol":{"$ref":"#/components/schemas/Mcp.Protocol"}},"required":["type","command"],"additionalProperties":false},"Config.LSP.ServerEncoded":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}},"disabled":{"type":"boolean"},"env":{"type":"object","additionalProperties":{"type":"string"}},"initialization":{"type":"object"}},"required":["command"],"additionalProperties":false},"Config.Formatter.EntryEncoded":{"type":"object","properties":{"disabled":{"type":"boolean"},"command":{"type":"array","items":{"type":"string"}},"environment":{"type":"object","additionalProperties":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"Config.AgentEncoded":{"type":"object","properties":{"model":{"anyOf":[{"type":"string","pattern":"^[^/#]+\\/[^#]+(?:#[^#]+)?$"},{"type":"object","properties":{"providerID":{"type":"string","pattern":"^[^/#]+$"},"model":{"type":"string","pattern":"^[^#]+$"},"variant":{"type":"string","pattern":"^[^#]+$"}},"required":["providerID","model"],"additionalProperties":false}]},"request":{"type":"object","properties":{"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"}},"additionalProperties":false},"system":{"type":"string"},"description":{"type":"string"},"mode":{"type":"string","enum":["subagent","primary","all"]},"hidden":{"type":"boolean"},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"steps":{"type":"integer","exclusiveMinimum":0},"disabled":{"type":"boolean"},"permissions":{"$ref":"#/components/schemas/Permission.Ruleset"}},"additionalProperties":false},"Permission.Ruleset":{"type":"array","items":{"$ref":"#/components/schemas/Permission.Rule"}},"Permission.Rule":{"type":"object","properties":{"action":{"type":"string"},"resource":{"type":"string"},"effect":{"$ref":"#/components/schemas/Permission.Effect"}},"required":["action","resource","effect"],"additionalProperties":false},"Permission.Effect":{"type":"string","enum":["allow","deny","ask"]},"ServiceUnavailableErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["ServiceUnavailableError"]},"message":{"type":"string"},"service":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["_tag","message"],"additionalProperties":false},"WebSearch.ResponseEncoded":{"type":"object","properties":{"providerID":{"$ref":"#/components/schemas/WebSearch.ID"},"results":{"type":"array","items":{"$ref":"#/components/schemas/WebSearch.Result"}}},"required":["providerID","results"],"additionalProperties":false},"WebSearch.Result":{"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"},"content":{"type":"string"},"time":{"type":"object","properties":{"published":{"type":"number"}},"additionalProperties":false}},"required":["url","time"],"additionalProperties":false},"WebSearch.Provider":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/WebSearch.ID"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":false},"FileDiff.Info":{"type":"object","properties":{"file":{"type":"string"},"patch":{"type":"string"},"additions":{"type":"integer","minimum":0},"deletions":{"type":"integer","minimum":0},"status":{"type":"string","enum":["added","deleted","modified"]}},"required":["file","patch","additions","deletions","status"],"additionalProperties":false},"Vcs.Mode":{"type":"string","enum":["working","branch","committed"]},"Vcs.BranchList":{"type":"array","items":{"type":"string"}},"Vcs.FileStatus":{"type":"object","properties":{"file":{"type":"string"},"additions":{"type":"integer","minimum":0},"deletions":{"type":"integer","minimum":0},"status":{"type":"string","enum":["added","deleted","modified"]}},"required":["file","additions","deletions","status"],"additionalProperties":false},"Vcs.Base":{"type":"object","properties":{"name":{"type":"string"},"ref":{"type":"string"},"source":{"type":"string","enum":["reflog","default"]}},"required":["name","ref","source"],"additionalProperties":false},"Vcs.Info":{"type":"object","properties":{"provider":{"type":"string"},"branch":{"$ref":"#/components/schemas/Vcs.Branch"}},"required":["branch"],"additionalProperties":false},"Vcs.Branch":{"type":"object","properties":{"current":{"type":"string"},"default":{"type":"string"}},"additionalProperties":false},"VcsInitNotSupportedErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["VcsInitNotSupportedError"]},"providerID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","providerID","message"],"additionalProperties":false},"ConflictErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["ConflictError"]},"message":{"type":"string"},"resource":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["_tag","message"],"additionalProperties":false},"ProjectNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProjectNotFoundError"]},"projectID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","projectID","message"],"additionalProperties":false},"WorktreeErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["WorktreeError"]},"name":{"type":"string","enum":["WorktreeError"]},"data":{"type":"object","properties":{"message":{"type":"string"},"forceRequired":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"required":["message"],"additionalProperties":false}},"required":["_tag","name","data"],"additionalProperties":false},"Project.ID":{"type":"string"},"Worktree.RemoveInput":{"type":"object","properties":{"projectID":{"$ref":"#/components/schemas/Project.ID"},"directory":{"$ref":"#/components/schemas/AbsolutePath"},"force":{"type":"boolean"}},"required":["projectID","directory","force"],"additionalProperties":false},"Worktree.Info":{"type":"object","properties":{"directory":{"$ref":"#/components/schemas/AbsolutePath"}},"required":["directory"],"additionalProperties":false},"Worktree.CreateInput":{"type":"object","properties":{"projectID":{"$ref":"#/components/schemas/Project.ID"},"from":{"$ref":"#/components/schemas/AbsolutePath"},"branch":{"type":"string"},"directory":{"$ref":"#/components/schemas/AbsolutePath"},"name":{"type":"string"}},"required":["projectID"],"additionalProperties":false},"Worktree.List":{"type":"array","items":{"$ref":"#/components/schemas/Worktree.Directory"}},"Worktree.Directory":{"type":"object","properties":{"directory":{"$ref":"#/components/schemas/AbsolutePath"},"strategy":{"type":"string"}},"required":["directory"],"additionalProperties":false},"Reference.Info":{"type":"object","properties":{"name":{"type":"string"},"path":{"$ref":"#/components/schemas/AbsolutePath"},"description":{"type":"string"},"hidden":{"type":"boolean"},"source":{"$ref":"#/components/schemas/Reference.Source"}},"required":["name","path","source"],"additionalProperties":false},"Reference.Source":{"anyOf":[{"$ref":"#/components/schemas/Reference.LocalSource"},{"$ref":"#/components/schemas/Reference.GitSource"}]},"Reference.GitSource":{"type":"object","properties":{"type":{"type":"string","enum":["git"]},"repository":{"type":"string"},"branch":{"type":"string"}},"required":["type","repository"],"additionalProperties":false},"Reference.LocalSource":{"type":"object","properties":{"type":{"type":"string","enum":["local"]},"path":{"$ref":"#/components/schemas/AbsolutePath"}},"required":["type","path"],"additionalProperties":false},"Shell.ID":{"type":"string","pattern":"^sh_"},"ShellNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["ShellNotFoundError"]},"id":{"type":"string"},"message":{"type":"string"}},"required":["_tag","id","message"],"additionalProperties":false},"Shell.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Shell.ID"},"status":{"type":"string","enum":["running","exited","timeout","killed"]},"command":{"type":"string"},"cwd":{"type":"string"},"shell":{"type":"string"},"file":{"type":"string"},"pid":{"type":"integer","minimum":0},"exit":{"type":"number"},"signal":{"type":"string"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"started":{"type":"number","description":"Start time in milliseconds since the Unix epoch"},"completed":{"type":"number"}},"required":["started"],"additionalProperties":false}},"required":["id","status","command","cwd","shell","file","metadata","time"],"additionalProperties":false},"PtyNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["PtyNotFoundError"]},"ptyID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","ptyID","message"],"additionalProperties":false},"ForbiddenErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["ForbiddenError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"Pty.ID":{"type":"string","pattern":"^pty"},"PtyTicket.ConnectToken":{"type":"object","properties":{"ticket":{"type":"string"},"expires_in":{"type":"integer","exclusiveMinimum":0}},"required":["ticket","expires_in"],"additionalProperties":false},"PersistentPty.Snapshot":{"type":"object","properties":{"info":{"$ref":"#/components/schemas/PersistentPty.Info"},"text":{"type":"string"},"checkpoint":{"type":"string","format":"byte","contentEncoding":"base64"},"cursor":{"type":"object","properties":{"x":{"type":"integer","minimum":0},"y":{"type":"integer","minimum":0}},"required":["x","y"],"additionalProperties":false}},"required":["info","text","checkpoint","cursor"],"additionalProperties":false},"PersistentPty.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Pty.ID"},"title":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"status":{"type":"string","enum":["running","exited"]},"pid":{"type":"integer","minimum":0},"exitCode":{"type":"integer","minimum":0},"sessionID":{"$ref":"#/components/schemas/Session.ID"},"foregroundProcess":{"anyOf":[{"type":"string"},{"type":"null"}]},"size":{"type":"object","properties":{"cols":{"type":"integer","exclusiveMinimum":0},"rows":{"type":"integer","exclusiveMinimum":0}},"required":["cols","rows"],"additionalProperties":false},"output":{"type":"object","properties":{"head":{"type":"integer","minimum":0},"tail":{"type":"integer","minimum":0}},"required":["head","tail"],"additionalProperties":false}},"required":["id","title","command","args","cwd","status","pid","sessionID","foregroundProcess","size","output"],"additionalProperties":false},"Session.ID":{"type":"string","pattern":"^ses"},"PersistentPty.UpdateInput":{"type":"object","properties":{"attachmentID":{"type":"string"},"size":{"type":"object","properties":{"cols":{"type":"integer","exclusiveMinimum":0},"rows":{"type":"integer","exclusiveMinimum":0}},"required":["cols","rows"],"additionalProperties":false}},"required":["size"],"additionalProperties":false},"PersistentPty.Handoff":{"type":"object","properties":{"directory":{"type":"string"},"instanceID":{"type":"string"},"ticket":{"type":"string"},"expiresAt":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["directory","instanceID","ticket","expiresAt"],"additionalProperties":false},"PersistentPty.CreateInput":{"type":"object","properties":{"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"title":{"type":"string"},"env":{"type":"object","additionalProperties":{"type":"string"}},"size":{"type":"object","properties":{"cols":{"type":"integer","exclusiveMinimum":0},"rows":{"type":"integer","exclusiveMinimum":0}},"required":["cols","rows"],"additionalProperties":false}},"required":["args","title","env"],"additionalProperties":false},"PersistentPty.ReadResult":{"type":"object","properties":{"ptyID":{"$ref":"#/components/schemas/Pty.ID"},"title":{"type":"string"},"cwd":{"type":"string"},"foregroundProcess":{"anyOf":[{"type":"string"},{"type":"null"}]},"screen":{"type":"object","properties":{"text":{"type":"string"},"cols":{"type":"integer","exclusiveMinimum":0},"rows":{"type":"integer","exclusiveMinimum":0},"cursor":{"type":"object","properties":{"x":{"type":"integer","minimum":0},"y":{"type":"integer","minimum":0}},"required":["x","y"],"additionalProperties":false}},"required":["text","cols","rows","cursor"],"additionalProperties":false}},"required":["ptyID","title","cwd","foregroundProcess","screen"],"additionalProperties":false},"PersistentPty.ReadLinesEncoded":{"type":"string"},"Pty":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Pty.ID"},"title":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"status":{"type":"string","enum":["running","exited"]},"pid":{"type":"integer","minimum":0},"exitCode":{"type":"integer","minimum":0}},"required":["id","title","command","args","cwd","status","pid"],"additionalProperties":false},"V2EventEncoded":{"type":"string","contentMediaType":"application/json"},"RpcInternalErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["RpcInternalError"]},"type":{"type":"string","enum":["rpc.internal","rpc.invalid_output"]},"message":{"type":"string"},"data":{"anyOf":[{},{"type":"null"}]}},"required":["_tag","type","message"],"additionalProperties":false},"RpcErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["RpcError"]},"type":{"type":"string"},"message":{"type":"string"},"data":{"anyOf":[{},{"type":"null"}]}},"required":["_tag","type","message"],"additionalProperties":false},"Rpc.Output":{"type":"object","properties":{"output":{}},"additionalProperties":false},"Rpc.Input":{"type":"object","properties":{"input":{}},"additionalProperties":false},"Skill.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Skill.ID"},"name":{"$ref":"#/components/schemas/Skill.Name"},"description":{"type":"string"},"autoinvoke":{"type":"boolean"},"path":{"$ref":"#/components/schemas/AbsolutePath"},"content":{"type":"string"}},"required":["id","name","path","content"],"additionalProperties":false},"Skill.Name":{"type":"string"},"Skill.ID":{"type":"string"},"Command.Info":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}},"required":["name"],"additionalProperties":false},"FileSystem.Write":{"type":"object","properties":{"path":{"$ref":"#/components/schemas/AbsolutePath"}},"required":["path"],"additionalProperties":false},"FileSystem.Entry":{"type":"object","properties":{"path":{"$ref":"#/components/schemas/RelativePath"},"type":{"type":"string","enum":["file","directory"]}},"required":["path","type"],"additionalProperties":false},"RelativePath":{"type":"string"},"FileNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["FileNotFoundError"]},"path":{"type":"string"},"message":{"type":"string"}},"required":["_tag","path","message"],"additionalProperties":false},"PermissionNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["PermissionNotFoundError"]},"requestID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","requestID","message"],"additionalProperties":false},"SessionNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["SessionNotFoundError"]},"sessionID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","sessionID","message"],"additionalProperties":false},"Permission.ID":{"type":"string","pattern":"^per"},"Permission.Reply":{"type":"string","enum":["once","always","reject"]},"Permission.Request":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Permission.ID"},"sessionID":{"$ref":"#/components/schemas/Session.ID"},"action":{"type":"string"},"resources":{"type":"array","items":{"type":"string"}},"save":{"type":"array","items":{"type":"string"}},"metadata":{"type":"object"},"source":{"$ref":"#/components/schemas/Permission.Source"},"message":{"type":"string"}},"required":["id","sessionID","action","resources"],"additionalProperties":false},"Permission.Source":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["tool"]},"messageID":{"type":"string"},"id":{"type":"string"}},"required":["type","messageID","id"],"additionalProperties":false}]},"Agent.ID":{"type":"string"},"PermissionSaved.ID":{"type":"string"},"PermissionSaved.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/PermissionSaved.ID"},"projectID":{"$ref":"#/components/schemas/Project.ID"},"action":{"type":"string"},"resource":{"type":"string"},"time":{"type":"object","properties":{"created":{"type":"number"},"updated":{"type":"number"}},"required":["created","updated"],"additionalProperties":false}},"required":["id","projectID","action","resource","time"],"additionalProperties":false},"Form.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Form.ID"},"sessionID":{"type":"string"},"title":{"type":"string"},"metadata":{"$ref":"#/components/schemas/Form.Metadata"},"fields":{"$ref":"#/components/schemas/Form.Fields"}},"required":["id","sessionID","title","fields"],"additionalProperties":false},"Form.Fields":{"type":"array","prefixItems":[{"$ref":"#/components/schemas/Form.Field"}],"minItems":1,"items":{"$ref":"#/components/schemas/Form.Field"}},"Form.Field":{"anyOf":[{"$ref":"#/components/schemas/Form.StringField"},{"$ref":"#/components/schemas/Form.NumberField"},{"$ref":"#/components/schemas/Form.IntegerField"},{"$ref":"#/components/schemas/Form.BooleanField"},{"$ref":"#/components/schemas/Form.MultiselectField"},{"$ref":"#/components/schemas/Form.ExternalField"}]},"Form.ExternalField":{"type":"object","properties":{"key":{"type":"string"},"type":{"type":"string","enum":["external"]},"url":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"}},"required":["key","type","url"],"additionalProperties":false},"Form.MultiselectField":{"type":"object","properties":{"key":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"required":{"type":"boolean"},"hidden":{"type":"boolean"},"when":{"type":"array","items":{"$ref":"#/components/schemas/Form.When"}},"type":{"type":"string","enum":["multiselect"]},"options":{"type":"array","items":{"$ref":"#/components/schemas/Form.Option"}},"minItems":{"type":"integer","minimum":0},"maxItems":{"type":"integer","minimum":0},"custom":{"type":"boolean"},"default":{"type":"array","items":{"type":"string"}}},"required":["key","type","options"],"additionalProperties":false},"Form.Option":{"type":"object","properties":{"value":{"type":"string"},"label":{"type":"string"},"description":{"type":"string"}},"required":["value","label"],"additionalProperties":false},"Form.When":{"type":"object","properties":{"key":{"type":"string"},"op":{"type":"string","enum":["eq","neq"]},"value":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},{"type":"boolean"}]}},"required":["key","op","value"],"additionalProperties":false},"Form.BooleanField":{"type":"object","properties":{"key":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"required":{"type":"boolean"},"hidden":{"type":"boolean"},"when":{"type":"array","items":{"$ref":"#/components/schemas/Form.When"}},"type":{"type":"string","enum":["boolean"]},"default":{"type":"boolean"}},"required":["key","type"],"additionalProperties":false},"Form.IntegerField":{"type":"object","properties":{"key":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"required":{"type":"boolean"},"hidden":{"type":"boolean"},"when":{"type":"array","items":{"$ref":"#/components/schemas/Form.When"}},"type":{"type":"string","enum":["integer"]},"minimum":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"maximum":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"default":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["key","type"],"additionalProperties":false},"Form.NumberField":{"type":"object","properties":{"key":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"required":{"type":"boolean"},"hidden":{"type":"boolean"},"when":{"type":"array","items":{"$ref":"#/components/schemas/Form.When"}},"type":{"type":"string","enum":["number"]},"minimum":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"maximum":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"default":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["key","type"],"additionalProperties":false},"Form.StringField":{"type":"object","properties":{"key":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"required":{"type":"boolean"},"hidden":{"type":"boolean"},"when":{"type":"array","items":{"$ref":"#/components/schemas/Form.When"}},"type":{"type":"string","enum":["string"]},"format":{"type":"string","enum":["email","uri","date","date-time"]},"minLength":{"type":"integer","minimum":0},"maxLength":{"type":"integer","minimum":0},"pattern":{"type":"string"},"placeholder":{"type":"string"},"default":{"type":"string"},"options":{"type":"array","items":{"$ref":"#/components/schemas/Form.Option"}},"custom":{"type":"boolean"}},"required":["key","type"],"additionalProperties":false},"Form.Metadata":{"type":"object"},"Form.ID":{"type":"string","pattern":"^frm_"},"Project":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Project.ID"},"canonical":{"$ref":"#/components/schemas/AbsolutePath"},"vcs":{"$ref":"#/components/schemas/Project.Vcs"},"name":{"type":"string"},"icon":{"$ref":"#/components/schemas/Project.Icon"},"commands":{"$ref":"#/components/schemas/Project.Commands"},"time":{"$ref":"#/components/schemas/Project.Time"},"sandboxes":{"type":"array","items":{"type":"string"}}},"required":["id","canonical","time","sandboxes"],"additionalProperties":false},"Project.Time":{"type":"object","properties":{"created":{"type":"integer","minimum":0},"updated":{"type":"integer","minimum":0},"active":{"type":"integer","minimum":0},"archived":{"type":"integer","minimum":0}},"required":["created","updated","active"],"additionalProperties":false},"Project.Commands":{"type":"object","properties":{"start":{"type":"string","description":"Startup script to run when creating a new workspace (worktree)"}},"additionalProperties":false},"Project.Icon":{"type":"object","properties":{"url":{"type":"string"},"override":{"type":"string"},"color":{"type":"string"}},"additionalProperties":false},"Project.Vcs":{"type":"string","pattern":"^[a-z][a-z0-9._-]*$"},"Credential.ID":{"type":"string"},"Credential.Entry":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Credential.ID"},"integrationID":{"$ref":"#/components/schemas/Integration.ID"},"label":{"type":"string"},"active":{"type":"boolean"},"value":{"$ref":"#/components/schemas/Credential.Value"}},"required":["id","integrationID","label","active","value"],"additionalProperties":false},"Credential.Value":{"anyOf":[{"$ref":"#/components/schemas/Credential.OAuth"},{"$ref":"#/components/schemas/Credential.Key"},{"$ref":"#/components/schemas/Credential.External"}]},"Credential.External":{"type":"object","properties":{"type":{"type":"string","enum":["external"]},"methodID":{"$ref":"#/components/schemas/Integration.MethodID"},"metadata":{"type":"object"}},"required":["type","methodID"],"additionalProperties":false},"Integration.MethodID":{"type":"string"},"Credential.Key":{"type":"object","properties":{"type":{"type":"string","enum":["key"]},"key":{"type":"string"},"metadata":{"type":"object"},"configuration":{"$ref":"#/components/schemas/Form.Answer_1"}},"required":["type","key"],"additionalProperties":false},"Form.Answer_1":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Form.Value"}},"Form.Value":{"anyOf":[{"type":"string"},{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},{"type":"boolean"},{"type":"array","items":{"type":"string"}}]},"Credential.OAuth":{"type":"object","properties":{"type":{"type":"string","enum":["oauth"]},"methodID":{"$ref":"#/components/schemas/Integration.MethodID"},"refresh":{"type":"string"},"access":{"type":"string"},"expires":{"type":"integer","minimum":0},"metadata":{"type":"object"}},"required":["type","methodID","refresh","access","expires"],"additionalProperties":false},"Integration.ID":{"type":"string"},"Credential.CreateInput":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Credential.ID"},"integrationID":{"$ref":"#/components/schemas/Integration.ID"},"label":{"type":"string"},"value":{"$ref":"#/components/schemas/Credential.Value"},"activate":{"type":"boolean"}},"required":["integrationID","value"],"additionalProperties":false},"Mcp.ResourceCatalog":{"type":"object","properties":{"resources":{"type":"array","items":{"$ref":"#/components/schemas/Mcp.Resource"}},"templates":{"type":"array","items":{"$ref":"#/components/schemas/Mcp.ResourceTemplate"}}},"required":["resources","templates"],"additionalProperties":false},"Mcp.ResourceTemplate":{"type":"object","properties":{"server":{"type":"string"},"name":{"type":"string"},"uriTemplate":{"type":"string"},"description":{"type":"string"},"mimeType":{"type":"string"}},"required":["server","name","uriTemplate"],"additionalProperties":false},"Mcp.Resource":{"type":"object","properties":{"server":{"type":"string"},"name":{"type":"string"},"uri":{"type":"string"},"description":{"type":"string"},"mimeType":{"type":"string"}},"required":["server","name","uri"],"additionalProperties":false},"McpServerNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["McpServerNotFoundError"]},"server":{"type":"string"},"message":{"type":"string"}},"required":["_tag","server","message"],"additionalProperties":false},"Mcp.Server":{"type":"object","properties":{"name":{"type":"string"},"status":{"anyOf":[{"$ref":"#/components/schemas/Mcp.Status.Connected"},{"$ref":"#/components/schemas/Mcp.Status.Pending"},{"$ref":"#/components/schemas/Mcp.Status.Disabled"},{"$ref":"#/components/schemas/Mcp.Status.Failed"},{"$ref":"#/components/schemas/Mcp.Status.NeedsAuth"}]},"integrationID":{"$ref":"#/components/schemas/Integration.ID"}},"required":["name","status"],"additionalProperties":false},"Mcp.Status.NeedsAuth":{"type":"object","properties":{"status":{"type":"string","enum":["needs_auth"]},"error":{"type":"string"}},"required":["status","error"],"additionalProperties":false},"Mcp.Status.Failed":{"type":"object","properties":{"status":{"type":"string","enum":["failed"]},"error":{"type":"string"}},"required":["status","error"],"additionalProperties":false},"Mcp.Status.Disabled":{"type":"object","properties":{"status":{"type":"string","enum":["disabled"]}},"required":["status"],"additionalProperties":false},"Mcp.Status.Pending":{"type":"object","properties":{"status":{"type":"string","enum":["pending"]}},"required":["status"],"additionalProperties":false},"Mcp.Status.Connected":{"type":"object","properties":{"status":{"type":"string","enum":["connected"]}},"required":["status"],"additionalProperties":false},"Integration.AttemptID":{"type":"string"},"IntegrationAttemptNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["IntegrationAttemptNotFoundError"]},"integrationID":{"type":"string"},"attemptID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","integrationID","attemptID","message"],"additionalProperties":false},"IntegrationNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["IntegrationNotFoundError"]},"integrationID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","integrationID","message"],"additionalProperties":false},"Integration.CommandAttemptStatus":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["pending"]},"message":{"type":"string"},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","time"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["complete"]},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","time"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["failed"]},"message":{"type":"string"},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","message","time"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["expired"]},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","time"],"additionalProperties":false}]},"IntegrationMethodNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["IntegrationMethodNotFoundError"]},"integrationID":{"type":"string"},"methodID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","integrationID","methodID","message"],"additionalProperties":false},"Integration.CommandAttempt":{"type":"object","properties":{"attemptID":{"$ref":"#/components/schemas/Integration.AttemptID"},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["attemptID","time"],"additionalProperties":false},"Integration.AttemptStatus":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["pending"]},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","time"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["complete"]},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","time"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["failed"]},"message":{"type":"string"},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","message","time"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["expired"]},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["status","time"],"additionalProperties":false}]},"Integration.AttemptEncoded":{"type":"object","properties":{"attemptID":{"$ref":"#/components/schemas/Integration.AttemptID"},"url":{"type":"string"},"instructions":{"type":"string"},"mode":{"type":"string","enum":["auto","code"]},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"expires":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["created","expires"],"additionalProperties":false}},"required":["attemptID","url","instructions","mode","time"],"additionalProperties":false},"Form.Answer":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/Form.Value"}},"Integration.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Integration.ID"},"name":{"type":"string"},"metadata":{"type":"object"},"methods":{"type":"array","items":{"$ref":"#/components/schemas/Integration.Method"}},"connections":{"type":"array","items":{"$ref":"#/components/schemas/Connection.Info"}}},"required":["id","name","methods","connections"],"additionalProperties":false},"Connection.Info":{"anyOf":[{"$ref":"#/components/schemas/Connection.CredentialInfo"},{"$ref":"#/components/schemas/Connection.EnvInfo"}]},"Connection.EnvInfo":{"type":"object","properties":{"type":{"type":"string","enum":["env"]},"name":{"type":"string"},"status":{"$ref":"#/components/schemas/Connection.Status"}},"required":["type","name"],"additionalProperties":false},"Connection.Status":{"type":"object","properties":{"status":{"anyOf":[{"type":"string","enum":["needs_auth"]}]},"message":{"type":"string"},"url":{"type":"string"}},"required":["status","message"],"additionalProperties":false},"Connection.CredentialInfo":{"type":"object","properties":{"type":{"type":"string","enum":["credential"]},"id":{"$ref":"#/components/schemas/Credential.ID"},"label":{"type":"string"},"method":{"type":"string","enum":["key","oauth","external"]},"status":{"$ref":"#/components/schemas/Connection.Status"}},"required":["type","id","label","method"],"additionalProperties":false},"Integration.Method":{"anyOf":[{"$ref":"#/components/schemas/Integration.OAuthMethod"},{"$ref":"#/components/schemas/Integration.CommandMethod"},{"$ref":"#/components/schemas/Integration.KeyMethod"},{"$ref":"#/components/schemas/Integration.ExternalMethod"},{"$ref":"#/components/schemas/Integration.EnvMethod"}]},"Integration.EnvMethod":{"type":"object","properties":{"type":{"type":"string","enum":["env"]},"names":{"type":"array","items":{"type":"string"}}},"required":["type","names"],"additionalProperties":false},"Integration.ExternalMethod":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Integration.MethodID"},"type":{"type":"string","enum":["external"]},"label":{"type":"string"},"form":{"$ref":"#/components/schemas/Form.Fields_3"}},"required":["id","type","label"],"additionalProperties":false},"Form.Fields_3":{"type":"array","prefixItems":[{"$ref":"#/components/schemas/Form.Field"}],"minItems":1,"items":{"$ref":"#/components/schemas/Form.Field"}},"Integration.KeyMethod":{"type":"object","properties":{"type":{"type":"string","enum":["key"]},"label":{"type":"string"},"form":{"$ref":"#/components/schemas/Form.Fields_2"}},"required":["type"],"additionalProperties":false},"Form.Fields_2":{"type":"array","prefixItems":[{"$ref":"#/components/schemas/Form.Field"}],"minItems":1,"items":{"$ref":"#/components/schemas/Form.Field"}},"Integration.CommandMethod":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Integration.MethodID"},"type":{"type":"string","enum":["command"]},"label":{"type":"string"},"command":{"type":"array","items":{"type":"string"}}},"required":["id","type","label","command"],"additionalProperties":false},"Integration.OAuthMethod":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Integration.MethodID"},"type":{"type":"string","enum":["oauth"]},"label":{"type":"string"},"form":{"$ref":"#/components/schemas/Form.Fields_1"}},"required":["id","type","label"],"additionalProperties":false},"Form.Fields_1":{"type":"array","prefixItems":[{"$ref":"#/components/schemas/Form.Field"}],"minItems":1,"items":{"$ref":"#/components/schemas/Form.Field"}},"ProviderNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["ProviderNotFoundError"]},"providerID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","providerID","message"],"additionalProperties":false},"Provider.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Provider.ID"},"canonical":{"$ref":"#/components/schemas/Provider.ID"},"integrationID":{"$ref":"#/components/schemas/Integration.ID"},"name":{"type":"string"},"activation":{"type":"string","enum":["auto","enabled","disabled"]},"package":{"type":"string"},"compatibility":{"$ref":"#/components/schemas/Provider.Compatibility"},"settings":{"$ref":"#/components/schemas/Provider.Settings"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"}},"required":["id","name","activation","package"],"additionalProperties":false},"Provider.Settings":{"type":"object","properties":{"timeout":{"anyOf":[{"type":"number"},{"type":"boolean","enum":[false]}]},"headerTimeout":{"anyOf":[{"type":"number"},{"type":"boolean","enum":[false]}]},"chunkTimeout":{"anyOf":[{"type":"number"},{"type":"boolean","enum":[false]}]},"compaction":{"$ref":"#/components/schemas/Provider.Compaction"},"transport":{"$ref":"#/components/schemas/Provider.Transport"},"encoding":{"$ref":"#/components/schemas/Provider.Encoding"}},"allOf":[{"type":"object","additionalProperties":{}}]},"GenerateTextResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}},"required":["data"],"additionalProperties":false},"Model.Ref":{"type":"object","properties":{"id":{"type":"string"},"providerID":{"$ref":"#/components/schemas/Provider.ID"},"variant":{"type":"string"}},"required":["id","providerID"],"additionalProperties":false},"Model.Info":{"type":"object","properties":{"id":{"type":"string"},"modelID":{"type":"string"},"providerID":{"$ref":"#/components/schemas/Provider.ID"},"canonical":{"$ref":"#/components/schemas/Provider.ID"},"family":{"type":"string"},"name":{"type":"string"},"compatibility":{"$ref":"#/components/schemas/Model.Compatibility"},"package":{"type":"string"},"settings":{"$ref":"#/components/schemas/Model.Settings"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"},"capabilities":{"$ref":"#/components/schemas/Model.Capabilities"},"variants":{"type":"array","items":{"$ref":"#/components/schemas/Model.Variant"}},"time":{"type":"object","properties":{"released":{"type":"number"}},"required":["released"],"additionalProperties":false},"cost":{"type":"array","items":{"$ref":"#/components/schemas/Model.Cost"}},"status":{"type":"string","enum":["alpha","beta","deprecated","active"]},"enabled":{"type":"boolean"},"limit":{"type":"object","properties":{"context":{"type":"integer"},"input":{"type":"integer"},"output":{"type":"integer"}},"required":["context","output"],"additionalProperties":false}},"required":["id","modelID","providerID","name","capabilities","variants","time","cost","status","enabled","limit"],"additionalProperties":false},"Model.Cost":{"type":"object","properties":{"tier":{"type":"object","properties":{"type":{"type":"string","enum":["context"]},"size":{"type":"integer"}},"required":["type","size"],"additionalProperties":false},"input":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"},"output":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"},"cache":{"type":"object","properties":{"read":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"},"write":{"$ref":"#/components/schemas/Money.USDPerMillionTokens"}},"required":["read","write"],"additionalProperties":false}},"required":["input","output","cache"],"additionalProperties":false},"Model.Variant":{"type":"object","properties":{"id":{"type":"string"},"settings":{"$ref":"#/components/schemas/Model.Settings"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"}},"required":["id"],"additionalProperties":false},"Model.Settings":{"type":"object","properties":{"compaction":{"$ref":"#/components/schemas/Provider.Compaction"},"encoding":{"$ref":"#/components/schemas/Provider.Encoding"}},"allOf":[{"type":"object","additionalProperties":{}}]},"Model.Capabilities":{"type":"object","properties":{"tools":{"type":"boolean"},"input":{"type":"array","items":{"type":"string"}},"output":{"type":"array","items":{"type":"string"}}},"required":["tools","input","output"],"additionalProperties":false},"UnknownErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["UnknownError"]},"message":{"type":"string"},"ref":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["_tag","message"],"additionalProperties":false},"InvalidCursorErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["InvalidCursorError"]},"message":{"type":"string"}},"required":["_tag","message"],"additionalProperties":false},"SessionMessagesResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Session.Message.Info"}},"cursor":{"type":"object","properties":{"previous":{"anyOf":[{"type":"string"},{"type":"null"}]},"next":{"anyOf":[{"type":"string"},{"type":"null"}]}},"additionalProperties":false}},"required":["data","cursor"],"additionalProperties":false},"Session.Message.Info":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.AgentSelected"},{"$ref":"#/components/schemas/Session.Message.ModelSelected"},{"$ref":"#/components/schemas/Session.Message.LocationSwitched"},{"$ref":"#/components/schemas/Session.Message.User"},{"$ref":"#/components/schemas/Session.Message.Synthetic"},{"$ref":"#/components/schemas/Session.Message.System"},{"$ref":"#/components/schemas/Session.Message.Skill"},{"$ref":"#/components/schemas/Session.Message.Shell"},{"$ref":"#/components/schemas/Session.Message.Assistant"},{"$ref":"#/components/schemas/Session.Message.Compaction"},{"$ref":"#/components/schemas/Session.Message.Idle"}]},"Session.Message.Idle":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["idle"]},"outcome":{"type":"string","enum":["succeeded","failed","interrupted"]},"error":{"$ref":"#/components/schemas/Session.StructuredError"}},"required":["id","time","type","outcome"],"additionalProperties":false},"Session.StructuredError":{"type":"object","properties":{"type":{"type":"string"},"message":{"type":"string"},"status":{"type":"integer","minimum":100,"maximum":599},"response":{"type":"object","properties":{"body":{"type":"string"}},"required":["body"],"additionalProperties":false}},"required":["type","message"],"additionalProperties":false},"Session.Message.ID":{"type":"string","pattern":"^msg_"},"Session.Message.Compaction":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.Compaction.Running"},{"$ref":"#/components/schemas/Session.Message.Compaction.Completed"},{"$ref":"#/components/schemas/Session.Message.Compaction.Failed"}]},"Session.Message.Compaction.Failed":{"type":"object","properties":{"type":{"type":"string","enum":["compaction"]},"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"status":{"type":"string","enum":["failed"]},"reason":{"type":"string","enum":["auto","manual"]},"error":{"$ref":"#/components/schemas/Session.StructuredError"},"cost":{"$ref":"#/components/schemas/Money.USD"},"tokens":{"$ref":"#/components/schemas/TokenUsage.Info"}},"required":["type","id","time","status","reason","error"],"additionalProperties":false},"TokenUsage.Info":{"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"],"additionalProperties":false}},"required":["input","output","reasoning","cache"],"additionalProperties":false},"Money.USD":{"type":"number"},"Session.Message.Compaction.Completed":{"type":"object","properties":{"type":{"type":"string","enum":["compaction"]},"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"status":{"type":"string","enum":["completed"]},"reason":{"type":"string","enum":["auto","manual"]},"model":{"$ref":"#/components/schemas/Model.Ref"},"providerState":{"$ref":"#/components/schemas/Session.Message.ProviderState_5"},"summary":{"type":"string"},"recent":{"type":"string"},"providerContext":{"$ref":"#/components/schemas/Session.ProviderContext"},"cost":{"$ref":"#/components/schemas/Money.USD"},"tokens":{"$ref":"#/components/schemas/TokenUsage.Info"}},"required":["type","id","time","status","reason","summary","recent"],"additionalProperties":false},"Session.ProviderContext":{"type":"object","properties":{"version":{"type":"number","enum":[1]},"provenance":{"$ref":"#/components/schemas/Session.ProviderContext.Provenance"},"messages":{}},"required":["version","provenance","messages"],"additionalProperties":false},"Session.ProviderContext.Provenance":{"type":"object","properties":{"providerID":{"$ref":"#/components/schemas/Provider.ID"},"provider":{"type":"string"},"modelID":{"type":"string"},"route":{"type":"string"},"protocol":{"type":"string"},"endpoint":{"type":"string"}},"required":["providerID","provider","modelID","route","protocol","endpoint"],"additionalProperties":false},"Session.Message.ProviderState_5":{"type":"object"},"Session.Message.Compaction.Running":{"type":"object","properties":{"type":{"type":"string","enum":["compaction"]},"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"status":{"type":"string","enum":["running"]},"reason":{"type":"string","enum":["auto","manual"]},"summary":{"type":"string"},"recent":{"type":"string"}},"required":["type","id","time","status","reason","summary","recent"],"additionalProperties":false},"Session.Message.Assistant":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"},"streamed":{"type":"number"},"completed":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["assistant"]},"agent":{"$ref":"#/components/schemas/Agent.ID"},"model":{"$ref":"#/components/schemas/Model.Ref"},"content":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.Assistant.Text"},{"$ref":"#/components/schemas/Session.Message.Assistant.Reasoning"},{"$ref":"#/components/schemas/Session.Message.Assistant.Tool"}]}},"snapshot":{"type":"object","properties":{"start":{"$ref":"#/components/schemas/Snapshot.ID"},"end":{"$ref":"#/components/schemas/Snapshot.ID"},"files":{"type":"array","items":{"$ref":"#/components/schemas/RelativePath"}}},"additionalProperties":false},"finish":{"type":"string","enum":["stop","length","tool-calls","content-filter","error","unknown"]},"rawFinish":{"type":"string"},"providerState":{"$ref":"#/components/schemas/Session.Message.ProviderState_4"},"cost":{"$ref":"#/components/schemas/Money.USD"},"tokens":{"$ref":"#/components/schemas/TokenUsage.Info"},"error":{"$ref":"#/components/schemas/Session.StructuredError"},"retry":{"$ref":"#/components/schemas/Session.Message.Assistant.Retry"}},"required":["id","time","type","agent","model","content"],"additionalProperties":false},"Session.Message.Assistant.Retry":{"type":"object","properties":{"attempt":{"type":"integer","exclusiveMinimum":0},"at":{"type":"number"},"error":{"$ref":"#/components/schemas/Session.StructuredError"}},"required":["attempt","at","error"],"additionalProperties":false},"Session.Message.ProviderState_4":{"type":"object"},"Snapshot.ID":{"type":"string"},"Session.Message.Assistant.Tool":{"type":"object","properties":{"type":{"type":"string","enum":["tool"]},"id":{"type":"string"},"name":{"type":"string"},"executed":{"type":"boolean"},"providerState":{"$ref":"#/components/schemas/Session.Message.ProviderState_2"},"providerResultState":{"$ref":"#/components/schemas/Session.Message.ProviderState_3"},"state":{"anyOf":[{"$ref":"#/components/schemas/Session.Message.ToolState.Streaming"},{"$ref":"#/components/schemas/Session.Message.ToolState.Running"},{"$ref":"#/components/schemas/Session.Message.ToolState.Completed"},{"$ref":"#/components/schemas/Session.Message.ToolState.Error"}]},"time":{"type":"object","properties":{"created":{"type":"number"},"ran":{"type":"number"},"completed":{"type":"number"}},"required":["created"],"additionalProperties":false}},"required":["type","id","name","state","time"],"additionalProperties":false},"Session.Message.ToolState.Error":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"input":{"type":"object"},"error":{"$ref":"#/components/schemas/Session.StructuredError"},"content":{"type":"array","prefixItems":[{"$ref":"#/components/schemas/Tool.Content"}],"minItems":1,"items":{"$ref":"#/components/schemas/Tool.Content"}},"metadata":{"type":"object"}},"required":["status","input","error"],"additionalProperties":false},"Tool.Content":{"anyOf":[{"$ref":"#/components/schemas/Tool.TextContent"},{"$ref":"#/components/schemas/Tool.FileContent"}]},"Tool.FileContent":{"type":"object","properties":{"type":{"type":"string","enum":["file"]},"uri":{"type":"string"},"mime":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["type","uri","mime"],"additionalProperties":false},"Tool.TextContent":{"type":"object","properties":{"type":{"type":"string","enum":["text"]},"text":{"type":"string"}},"required":["type","text"],"additionalProperties":false},"Session.Message.ToolState.Completed":{"type":"object","properties":{"status":{"type":"string","enum":["completed"]},"input":{"type":"object"},"content":{"type":"array","prefixItems":[{"$ref":"#/components/schemas/Tool.Content"}],"minItems":1,"items":{"$ref":"#/components/schemas/Tool.Content"}},"metadata":{"type":"object"}},"required":["status","input","content"],"additionalProperties":false},"Session.Message.ToolState.Running":{"type":"object","properties":{"status":{"type":"string","enum":["running"]},"input":{"type":"object"},"metadata":{"type":"object"}},"required":["status","input","metadata"],"additionalProperties":false},"Session.Message.ToolState.Streaming":{"type":"object","properties":{"status":{"type":"string","enum":["streaming"]},"input":{"type":"string"}},"required":["status","input"],"additionalProperties":false},"Session.Message.ProviderState_3":{"type":"object"},"Session.Message.ProviderState_2":{"type":"object"},"Session.Message.Assistant.Reasoning":{"type":"object","properties":{"type":{"type":"string","enum":["reasoning"]},"text":{"type":"string"},"state":{"$ref":"#/components/schemas/Session.Message.ProviderState_1"},"time":{"type":"object","properties":{"created":{"type":"number"},"completed":{"type":"number"}},"required":["created"],"additionalProperties":false}},"required":["type","text"],"additionalProperties":false},"Session.Message.ProviderState_1":{"type":"object"},"Session.Message.Assistant.Text":{"type":"object","properties":{"type":{"type":"string","enum":["text"]},"text":{"type":"string"},"state":{"$ref":"#/components/schemas/Session.Message.ProviderState"}},"required":["type","text"],"additionalProperties":false},"Session.Message.ProviderState":{"type":"object"},"Session.Message.Shell":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"},"completed":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["shell"]},"shellID":{"$ref":"#/components/schemas/Shell.ID"},"command":{"type":"string"},"status":{"type":"string","enum":["running","exited","timeout","killed"]},"exit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"output":{"type":"object","properties":{"output":{"type":"string"},"cursor":{"type":"integer","minimum":0},"size":{"type":"integer","minimum":0},"truncated":{"type":"boolean"}},"required":["output","cursor","size","truncated"],"additionalProperties":false}},"required":["id","time","type","shellID","command","status"],"additionalProperties":false},"Session.Message.Skill":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["skill"]},"skill":{"$ref":"#/components/schemas/Skill.ID"},"name":{"$ref":"#/components/schemas/Skill.Name"},"text":{"type":"string"}},"required":["id","time","type","skill","name","text"],"additionalProperties":false},"Session.Message.System":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["system"]},"text":{"type":"string"},"description":{"type":"string"}},"required":["id","time","type","text"],"additionalProperties":false},"Session.Message.Synthetic":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"text":{"type":"string"},"description":{"type":"string"},"type":{"type":"string","enum":["synthetic"]}},"required":["id","time","text","type"],"additionalProperties":false},"Session.Message.User":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"text":{"type":"string"},"files":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.FileAttachment"}},"agents":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.AgentAttachment"}},"skills":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.SkillAttachment"}},"type":{"type":"string","enum":["user"]}},"required":["id","time","text","type"],"additionalProperties":false},"Prompt.SkillAttachment":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Skill.ID"},"name":{"$ref":"#/components/schemas/Skill.Name"},"text":{"type":"string"},"mention":{"$ref":"#/components/schemas/Prompt.Mention"}},"required":["id","name"],"additionalProperties":false},"Prompt.Mention":{"type":"object","properties":{"start":{"type":"number"},"end":{"type":"number"},"text":{"type":"string"}},"required":["start","end","text"],"additionalProperties":false},"Prompt.AgentAttachment":{"type":"object","properties":{"name":{"type":"string"},"mention":{"$ref":"#/components/schemas/Prompt.Mention"}},"required":["name"],"additionalProperties":false},"Prompt.FileAttachment":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Prompt.Base64"},"mime":{"type":"string"},"source":{"$ref":"#/components/schemas/Prompt.FileSource"},"name":{"type":"string"},"description":{"type":"string"},"mention":{"$ref":"#/components/schemas/Prompt.Mention"}},"required":["data","mime","source"],"additionalProperties":false},"Prompt.FileSource":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["inline"]}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["uri"]},"uri":{"type":"string"}},"required":["type","uri"],"additionalProperties":false}]},"Prompt.Base64":{"type":"string","pattern":"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"},"Session.Message.LocationSwitched":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["location-switched"]},"projectID":{"$ref":"#/components/schemas/Project.ID"},"subpath":{"$ref":"#/components/schemas/RelativePath"},"location":{"$ref":"#/components/schemas/Location.PublicRef"},"previous":{"anyOf":[{"type":"object","properties":{"location":{"$ref":"#/components/schemas/Location.PublicRef"},"projectID":{"$ref":"#/components/schemas/Project.ID"},"subpath":{"$ref":"#/components/schemas/RelativePath"}},"required":["location"],"additionalProperties":false},{"type":"null"}]}},"required":["id","time","type","location"],"additionalProperties":false},"Session.Message.ModelSelected":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["model-switched"]},"model":{"$ref":"#/components/schemas/Model.Ref"},"previous":{"$ref":"#/components/schemas/Model.Ref"}},"required":["id","time","type","model"],"additionalProperties":false},"Session.Message.AgentSelected":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"metadata":{"type":"object"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["agent-switched"]},"agent":{"$ref":"#/components/schemas/Agent.ID"},"previous":{"$ref":"#/components/schemas/Agent.ID"}},"required":["id","time","type","agent"],"additionalProperties":false},"FormAlreadySettledErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["FormAlreadySettledError"]},"id":{"type":"string"},"message":{"type":"string"}},"required":["_tag","id","message"],"additionalProperties":false},"FormNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["FormNotFoundError"]},"id":{"type":"string"},"message":{"type":"string"}},"required":["_tag","id","message"],"additionalProperties":false},"FormInvalidAnswerErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["FormInvalidAnswerError"]},"id":{"type":"string"},"message":{"type":"string"}},"required":["_tag","id","message"],"additionalProperties":false},"Form.Reply":{"type":"object","properties":{"answer":{"$ref":"#/components/schemas/Form.Answer"}},"required":["answer"],"additionalProperties":false},"Form.Detail":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Form.ID"},"sessionID":{"type":"string"},"title":{"type":"string"},"metadata":{"$ref":"#/components/schemas/Form.Metadata"},"fields":{"$ref":"#/components/schemas/Form.Fields"},"state":{"$ref":"#/components/schemas/Form.State"}},"required":["id","sessionID","title","fields","state"],"additionalProperties":false},"Form.State":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["pending"]}},"required":["status"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["answered"]},"answer":{"$ref":"#/components/schemas/Form.Answer"}},"required":["status","answer"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["cancelled"]},"message":{"type":"string"}},"required":["status"],"additionalProperties":false}]},"Form.CreatePayload":{"type":"object","properties":{"id":{"anyOf":[{"$ref":"#/components/schemas/Form.ID"},{"type":"null"}]},"title":{"type":"string"},"metadata":{"$ref":"#/components/schemas/Form.Metadata"},"fields":{"$ref":"#/components/schemas/Form.Fields"}},"required":["title","fields"],"additionalProperties":false},"MessageNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["MessageNotFoundError"]},"sessionID":{"type":"string"},"messageID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","sessionID","messageID","message"],"additionalProperties":false},"SessionInterruptResponse":{"type":"object","properties":{"interrupted":{"type":"boolean","description":"Whether an active execution owned by this OpenCode process was interrupted."}},"required":["interrupted"],"additionalProperties":false},"SessionLogItemEncoded":{"type":"string","contentMediaType":"application/json"},"Event.SeqEncoded":{"type":"string"},"SessionGenerateResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}},"required":["data"],"additionalProperties":false},"InstructionEntry.Key":{"type":"string","pattern":"^[a-z0-9][a-z0-9._-]*$","description":"Instruction entry key (lowercase alphanumerics plus . _ -)"},"InstructionEntryValueTooLargeErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["InstructionEntryValueTooLargeError"]},"actualBytes":{"type":"integer"},"maxBytes":{"type":"integer"},"message":{"type":"string"}},"required":["_tag","actualBytes","maxBytes","message"],"additionalProperties":false},"InstructionEntry.Info":{"type":"object","properties":{"key":{"$ref":"#/components/schemas/InstructionEntry.Key"},"value":{"description":"JSON value attached to the session's instructions"}},"required":["key","value"],"additionalProperties":false},"Session.Inbox.Delivery":{"type":"string","enum":["steer","queue"]},"Session.Inbox.Info":{"anyOf":[{"$ref":"#/components/schemas/Session.Inbox.User"},{"$ref":"#/components/schemas/Session.Inbox.Synthetic"},{"$ref":"#/components/schemas/Session.Inbox.Compaction"},{"$ref":"#/components/schemas/Session.Inbox.Move"}]},"Session.Inbox.Move":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"sessionID":{"$ref":"#/components/schemas/Session.ID"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["move"]},"delivery":{"$ref":"#/components/schemas/Session.Inbox.Delivery"},"payload":{"$ref":"#/components/schemas/Session.Inbox.MovePayload"}},"required":["id","sessionID","time","type","delivery","payload"],"additionalProperties":false},"Session.Inbox.MovePayload":{"type":"object","properties":{"projectID":{"$ref":"#/components/schemas/Project.ID"},"subpath":{"$ref":"#/components/schemas/RelativePath"},"location":{"$ref":"#/components/schemas/Location.PublicRef"}},"required":["projectID","location"],"additionalProperties":false},"Session.Inbox.Compaction":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"sessionID":{"$ref":"#/components/schemas/Session.ID"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["compaction"]},"payload":{"$ref":"#/components/schemas/Session.Inbox.CompactionPayload"},"delivery":{"$ref":"#/components/schemas/Session.Inbox.Delivery"}},"required":["id","sessionID","time","type","payload","delivery"],"additionalProperties":false},"Session.Inbox.CompactionPayload":{"not":{"type":"null"}},"Session.Inbox.Synthetic":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"sessionID":{"$ref":"#/components/schemas/Session.ID"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["synthetic"]},"payload":{"$ref":"#/components/schemas/Session.Inbox.SyntheticPayload"},"delivery":{"$ref":"#/components/schemas/Session.Inbox.Delivery"}},"required":["id","sessionID","time","type","payload","delivery"],"additionalProperties":false},"Session.Inbox.SyntheticPayload":{"type":"object","properties":{"text":{"type":"string"},"description":{"type":"string"},"metadata":{"type":"object"}},"required":["text"],"additionalProperties":false},"Session.Inbox.User":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.Message.ID"},"sessionID":{"$ref":"#/components/schemas/Session.ID"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"],"additionalProperties":false},"type":{"type":"string","enum":["user"]},"payload":{"$ref":"#/components/schemas/Session.Inbox.UserPayload"},"delivery":{"$ref":"#/components/schemas/Session.Inbox.Delivery"}},"required":["id","sessionID","time","type","payload","delivery"],"additionalProperties":false},"Session.Inbox.UserPayload":{"type":"object","properties":{"text":{"type":"string"},"files":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.FileAttachment"}},"agents":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.AgentAttachment"}},"skills":{"type":"array","items":{"$ref":"#/components/schemas/Prompt.SkillAttachment"}},"metadata":{"type":"object"}},"required":["text"],"additionalProperties":false},"SessionBusyErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["SessionBusyError"]},"sessionID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","sessionID","message"],"additionalProperties":false},"Session.Revert":{"type":"object","properties":{"messageID":{"$ref":"#/components/schemas/Session.Message.ID"},"partID":{"type":"string"},"snapshot":{"$ref":"#/components/schemas/Snapshot.ID"},"files":{"type":"array","items":{"$ref":"#/components/schemas/FileDiff.Info"}}},"required":["messageID"],"additionalProperties":false},"SkillNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["SkillNotFoundError"]},"skill":{"$ref":"#/components/schemas/Skill.ID"},"message":{"type":"string"}},"required":["_tag","skill","message"],"additionalProperties":false},"CommandExecutionErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["CommandExecutionError"]},"command":{"type":"string"},"message":{"type":"string"}},"required":["_tag","command","message"],"additionalProperties":false},"CommandNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["CommandNotFoundError"]},"command":{"type":"string"},"message":{"type":"string"}},"required":["_tag","command","message"],"additionalProperties":false},"PromptInput.SkillAttachment":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Skill.ID"},"mention":{"$ref":"#/components/schemas/Prompt.Mention"}},"required":["id"],"additionalProperties":false},"PromptInput.FileAttachment":{"type":"object","properties":{"uri":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"mention":{"$ref":"#/components/schemas/Prompt.Mention"}},"required":["uri"],"additionalProperties":false},"Session.Metadata":{"type":"object"},"Session.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Session.ID"},"parentID":{"$ref":"#/components/schemas/Session.ID"},"fork":{"type":"object","properties":{"sessionID":{"$ref":"#/components/schemas/Session.ID"},"boundary":{"$ref":"#/components/schemas/Session.ForkBoundary"}},"required":["sessionID","boundary"],"additionalProperties":false},"projectID":{"$ref":"#/components/schemas/Project.ID"},"agent":{"$ref":"#/components/schemas/Agent.ID"},"model":{"$ref":"#/components/schemas/Model.Ref"},"cost":{"$ref":"#/components/schemas/Money.USD"},"tokens":{"$ref":"#/components/schemas/TokenUsage.Info"},"outcome":{"type":"string","enum":["succeeded","failed","interrupted"]},"time":{"type":"object","properties":{"created":{"type":"number"},"updated":{"type":"number"},"idle":{"type":"number"},"viewed":{"type":"number"},"archived":{"type":"number"}},"required":["created","updated"],"additionalProperties":false},"title":{"type":"string"},"subpath":{"$ref":"#/components/schemas/RelativePath"},"metadata":{"$ref":"#/components/schemas/Session.Metadata"},"permissions":{"$ref":"#/components/schemas/Permission.Ruleset"},"revert":{"$ref":"#/components/schemas/Session.Revert"},"location":{"$ref":"#/components/schemas/Location.PublicRef"}},"required":["id","projectID","cost","tokens","time","location"],"additionalProperties":false},"Session.ForkBoundary":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["before"]},"messageID":{"$ref":"#/components/schemas/Session.Message.ID"}},"required":["type","messageID"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["through"]},"messageID":{"$ref":"#/components/schemas/Session.Message.ID"}},"required":["type","messageID"],"additionalProperties":false}]},"SessionActive":{"type":"object","properties":{"type":{"type":"string","enum":["running"]}},"required":["type"],"additionalProperties":false},"SessionTransfer.Data":{"type":"object","properties":{"info":{"$ref":"#/components/schemas/Session.Info"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Session.Message.Info"}}},"required":["info","messages"],"additionalProperties":false},"SessionStats.Info":{"type":"object","properties":{"range":{"type":"object","properties":{"from":{"type":"number"},"to":{"type":"number"}},"required":["from","to"],"additionalProperties":false},"sessions":{"type":"integer","minimum":0},"subagents":{"type":"integer","minimum":0},"prompts":{"type":"integer","minimum":0},"steps":{"type":"integer","minimum":0},"tokens":{"$ref":"#/components/schemas/TokenUsage.Info"},"cost":{"$ref":"#/components/schemas/Money.USD"},"tools":{"$ref":"#/components/schemas/SessionStats.Tools"},"activeDays":{"type":"integer","minimum":0},"streak":{"type":"integer","minimum":0},"activity":{"type":"array","items":{"$ref":"#/components/schemas/SessionStats.Activity"}},"models":{"type":"array","items":{"$ref":"#/components/schemas/SessionStats.ModelUsage"}}},"required":["range","sessions","subagents","prompts","steps","tokens","cost","tools","activeDays","streak","activity","models"],"additionalProperties":false},"SessionStats.ModelUsage":{"type":"object","properties":{"model":{"$ref":"#/components/schemas/Model.Ref"},"steps":{"type":"integer","minimum":0},"tokens":{"$ref":"#/components/schemas/TokenUsage.Info"},"cost":{"$ref":"#/components/schemas/Money.USD"}},"required":["model","steps","tokens","cost"],"additionalProperties":false},"SessionStats.Activity":{"type":"object","properties":{"date":{"type":"string"},"steps":{"type":"integer","minimum":0}},"required":["date","steps"],"additionalProperties":false},"SessionStats.Tools":{"anyOf":[{"type":"object","properties":{"mode":{"type":"string","enum":["none"]}},"required":["mode"],"additionalProperties":false},{"type":"object","properties":{"mode":{"type":"string","enum":["summary"]},"totals":{"$ref":"#/components/schemas/SessionStats.ToolTotals"}},"required":["mode","totals"],"additionalProperties":false},{"type":"object","properties":{"mode":{"type":"string","enum":["detail"]},"totals":{"$ref":"#/components/schemas/SessionStats.ToolTotals"},"usage":{"type":"array","items":{"$ref":"#/components/schemas/SessionStats.ToolUsage"}}},"required":["mode","totals","usage"],"additionalProperties":false}]},"SessionStats.ToolUsage":{"type":"object","properties":{"name":{"type":"string"},"calls":{"type":"integer","minimum":0},"succeeded":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"unfinished":{"type":"integer","minimum":0},"durationP50":{"type":"number"}},"required":["name","calls","succeeded","failed","unfinished"],"additionalProperties":false},"SessionStats.ToolTotals":{"type":"object","properties":{"calls":{"type":"integer","minimum":0},"succeeded":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"unfinished":{"type":"integer","minimum":0}},"required":["calls","succeeded","failed","unfinished"],"additionalProperties":false},"SessionsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Session.Info"}},"cursor":{"type":"object","properties":{"previous":{"anyOf":[{"$ref":"#/components/schemas/SessionsCursor"},{"type":"null"}]},"next":{"anyOf":[{"$ref":"#/components/schemas/SessionsCursor"},{"type":"null"}]}},"additionalProperties":false}},"required":["data","cursor"],"additionalProperties":false},"SessionsCursor":{"type":"string","description":"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response."},"Plugin.Info":{"type":"object","properties":{"id":{"type":"string"},"source":{"$ref":"#/components/schemas/Plugin.Source"},"features":{"$ref":"#/components/schemas/Plugin.Features"},"state":{"$ref":"#/components/schemas/Plugin.State"}},"required":["source","features","state"],"additionalProperties":false},"Plugin.State":{"anyOf":[{"type":"object","properties":{"status":{"type":"string","enum":["active"]}},"required":["status"],"additionalProperties":false},{"type":"object","properties":{"status":{"type":"string","enum":["failed"]},"error":{"type":"string"},"ref":{"type":"string"}},"required":["status","error"],"additionalProperties":false}]},"Plugin.Features":{"type":"object","properties":{"server":{"type":"boolean","enum":[true]},"tui":{"type":"boolean","enum":[true]},"rpc":{"type":"boolean","enum":[true]}},"additionalProperties":false},"Plugin.Source":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","enum":["builtin"]}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["package"]},"target":{"type":"string"},"version":{"type":"string"},"outdated":{"type":"boolean","enum":[true]},"updating":{"type":"boolean","enum":[true]}},"required":["type","target"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["local"]},"path":{"type":"string"}},"required":["type","path"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["sdk"]}},"required":["type"],"additionalProperties":false}]},"AgentNotFoundErrorEncoded":{"type":"object","properties":{"_tag":{"type":"string","enum":["AgentNotFoundError"]},"agentID":{"type":"string"},"message":{"type":"string"}},"required":["_tag","agentID","message"],"additionalProperties":false},"Agent.Info":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Agent.ID"},"name":{"type":"string"},"model":{"$ref":"#/components/schemas/Model.Ref"},"request":{"$ref":"#/components/schemas/Provider.Request"},"system":{"type":"string"},"description":{"type":"string"},"mode":{"type":"string","enum":["subagent","primary","all"]},"hidden":{"type":"boolean"},"color":{"$ref":"#/components/schemas/Agent.Color"},"steps":{"type":"integer","exclusiveMinimum":0},"permissions":{"$ref":"#/components/schemas/Permission.Ruleset"}},"required":["id","name","request","mode","hidden","permissions"],"additionalProperties":false},"Agent.Color":{"type":"string"},"Provider.Request":{"type":"object","properties":{"settings":{"$ref":"#/components/schemas/Provider.Settings"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object"}},"required":["settings","headers","body"],"additionalProperties":false},"Location.PublicInfo":{"type":"object","properties":{"directory":{"$ref":"#/components/schemas/AbsolutePath"},"project":{"type":"object","properties":{"id":{"$ref":"#/components/schemas/Project.ID"},"directory":{"$ref":"#/components/schemas/AbsolutePath"},"canonical":{"$ref":"#/components/schemas/AbsolutePath"}},"required":["id","directory","canonical"],"additionalProperties":false}},"required":["directory","project"],"additionalProperties":false},"PairingSession":{"type":"object","properties":{"token":{"type":"string"}},"required":["token"],"additionalProperties":false},"PairingCode":{"type":"object","properties":{"code":{"type":"string"},"expires_in":{"type":"integer"}},"required":["code","expires_in"],"additionalProperties":false},"ServerInfo":{"type":"object","properties":{"version":{"type":"string"},"pid":{"type":"integer","minimum":0},"urls":{"type":"array","items":{"type":"string"}},"paths":{"type":"object","properties":{"tmp":{"type":"string"}},"required":["tmp"],"additionalProperties":false},"capabilities":{"anyOf":[{"type":"object","properties":{"persistentPty":{"anyOf":[{"type":"boolean"},{"type":"null"}]}},"additionalProperties":false},{"type":"null"}]}},"required":["version","pid","urls","paths"],"additionalProperties":false}},"securitySchemes":{}},"security":[],"tags":[{"name":"server"},{"name":"location"},{"name":"agent"},{"name":"plugin","description":"Experimental plugin routes."},{"name":"session","description":"Experimental session routes."},{"name":"session","description":"Experimental message routes."},{"name":"model","description":"Experimental model routes."},{"name":"generate","description":"Experimental one-shot generation routes."},{"name":"provider","description":"Experimental provider routes."},{"name":"integration","description":"Integration discovery and authentication routes."},{"name":"mcp","description":"MCP server and resource routes."},{"name":"credential"},{"name":"project","description":"Project routes."},{"name":"form","description":"Location form routes."},{"name":"permission","description":"Experimental permission routes."},{"name":"filesystem","description":"Experimental location-scoped filesystem routes."},{"name":"command","description":"Experimental command routes."},{"name":"skill","description":"Experimental skill routes."},{"name":"rpc","description":"Plugin RPC routes."},{"name":"event","description":"Experimental event stream routes."},{"name":"pty","description":"Experimental location-scoped PTY routes."},{"name":"persistentPty","description":"Prototype persistent PTY routes."},{"name":"shell","description":"Location-scoped shell command routes."},{"name":"reference","description":"Location-scoped project references."},{"name":"worktree","description":"Project-based worktree management routes."},{"name":"vcs","description":"Location-scoped version control routes."},{"name":"debug"},{"name":"migration"},{"name":"websearch","description":"Location-scoped web search routes."},{"name":"config","description":"Location-scoped configuration routes."}]} \ No newline at end of file diff --git a/docs/opencode-v2-port/sample-events.sse b/docs/opencode-v2-port/sample-events.sse new file mode 100644 index 000000000..68bedd6aa --- /dev/null +++ b/docs/opencode-v2-port/sample-events.sse @@ -0,0 +1,41 @@ +data: {"id":"evt_1cb8dede1001UDBqlQa3BARDLc","type":"server.connected","data":{}} +data: {"id":"evt_1cb8dedf10014hWTKi3Lygyv0E","created":1794416438769,"type":"session.created","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","slug":"lucky-rocket","version":"2.0.29","projectID":"9d0a77c481d8da42e86ea6a51203ff1d08a43f01","location":{"directory":"/home/agent/work/ruby-dcb"},"subpath":"","title":"probe"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":0,"version":1}} +data: {"id":"evt_1cb8dee0600149pX0WXjVmi0Ow","created":1794416438790,"type":"session.inbox.enqueued","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"inboxID":"msg_1cb8dee03001RpXshDbNwev1KM","sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","item":{"type":"user","payload":{"text":"Run `ls` in the repo with your shell tool, then reply with the number of entries. Nothing else."},"delivery":"steer"}},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":1,"version":1}} +data: {"id":"evt_1cb8dee0b001xqYxnRwvx7V7e3","created":1794416438795,"type":"session.execution.started","data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":2,"version":1}} +data: {"id":"evt_1cb8dee25001qYBkQ6G3I18O1E","created":1794416438821,"metadata":{"instructions":{"initial":true}},"type":"session.instructions.updated","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","delta":{"core/codemode":"2c39b88d95d896b12365026d407922200879ac290cd16abd72607c7ea08c3bcc","core/skill-guidance":"8394362d28c82fa4d13c7a9f5568c05680f6687e07db8f426a0e3c92f3027dc9","core/instructions":"d08cfd3949a8703ac1228427bd5bffbeea4549ef6fc730089530345594228f63","core/date":"8fda1d364fef8b5fbcccb039ef94b1ebe3705b3b3a6d558d6d10f92e84ff9e33","core/environment":"58e7ceb7376f4e89b6a8991792e5d0d515251cab4996dc3c4f9dd2fc5a7621d4"}},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":3,"version":2}} +data: {"id":"evt_1cb8dee29001sAr5UFBrYAGEUz","created":1794416438825,"type":"session.inbox.delivered","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","inboxID":"msg_1cb8dee03001RpXshDbNwev1KM"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":4,"version":1}} +data: {"id":"evt_1cb8e4af1001CUHaaGpzF7ZBTE","created":1794416462577,"type":"session.step.started","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","agent":"build","model":{"id":"work","providerID":"dgx"},"assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","snapshot":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","started":1794416438841},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":5,"version":1}} +data: {"id":"evt_1cb8e4af5001llVPCj9epVsF1O","created":1794416462581,"type":"session.reasoning.started","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","ordinal":0,"state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":6,"version":1}} +data: {"id":"evt_1cb8e4b5d0019mNanqTAIbLYHa","created":1794416462685,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","ordinal":0,"delta":"The user wants"}} +data: {"id":"evt_1cb8e4bd9001apgLWpOCIhARqX","created":1794416462809,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","ordinal":0,"delta":" me to run `ls` in the repo and reply"}} +data: {"id":"evt_1cb8e4c72001XAhZXQelsdUNri","created":1794416462963,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","ordinal":0,"delta":" with the number of entries. Just"}} +data: {"id":"evt_1cb8e4ce800108DDZNQPss2h47","created":1794416463080,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","ordinal":0,"delta":" do it"}} +data: {"id":"evt_1cb8e4d99001WWJQYAK1YASvWw","created":1794416463257,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","ordinal":0,"delta":"."}} +data: {"id":"evt_1cb8e4da4001Lc05Q8NO1jKDEw","created":1794416463268,"type":"session.tool.input.started","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","id":"call_792d2014cdc9167a91e4dc28","name":"shell"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":7,"version":1}} +data: {"id":"evt_1cb8e4e500012VekYFnm3H2slv","created":1794416463440,"type":"session.reasoning.ended","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","ordinal":0,"text":"The user wants me to run `ls` in the repo and reply with the number of entries. Just do it.","state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":8,"version":1}} +data: {"id":"evt_1cb8e4e52001kcpX6tQD0p6lBC","created":1794416463442,"type":"session.tool.input.ended","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","id":"call_792d2014cdc9167a91e4dc28","text":"{\"command\":\"ls\"}"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":9,"version":1}} +data: {"id":"evt_1cb8e4e53001lSTJqZ5IC3Z9RM","created":1794416463443,"type":"session.tool.called","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","id":"call_792d2014cdc9167a91e4dc28","input":{"command":"ls"},"executed":false},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":10,"version":1}} +data: {"id":"evt_1cb8e4e55001dF51LfrZA5aCC0","created":1794416463445,"type":"session.step.streamed","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":11,"version":1}} +data: {"id":"evt_1cb8e4e78001TegIsQrxz8ZpCO","created":1794416463480,"type":"shell.created","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"info":{"id":"sh_1cb8e4e75001LfTWpmckiOZF77","status":"running","command":"ls","cwd":"/home/agent/work/ruby-dcb","shell":"/usr/bin/bash","file":"/home/agent/.local/share/opencode/shell/9d0a77c481d8da42e86ea6a51203ff1d08a43f01/sh_1cb8e4e75001LfTWpmckiOZF77.out","metadata":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs"},"time":{"started":1794416463477}}}} +data: {"id":"evt_1cb8e4e78002DTox0f8fFVRu42","created":1794416463480,"type":"session.tool.progress","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","id":"call_792d2014cdc9167a91e4dc28","metadata":{"shellID":"sh_1cb8e4e75001LfTWpmckiOZF77"}}} +data: {"id":"evt_1cb8e4e7a0010EAK34Kl1CB5H4","created":1794416463482,"type":"shell.exited","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"id":"sh_1cb8e4e75001LfTWpmckiOZF77","exit":0,"status":"exited"}} +data: {"id":"evt_1cb8e4e7c001S9uf3s92LrWsV3","created":1794416463484,"type":"session.tool.success","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","id":"call_792d2014cdc9167a91e4dc28","content":[{"type":"text","text":"CLAUDE.md\nGemfile\nLICENSE\nREADME.md\nRakefile\nbench\nbin\ndcb_event_store.gemspec\ndocs\nexamples\nlib\nplan\nscripts\ntest\n"}],"metadata":{"status":"completed","truncated":false,"exit":0},"executed":false},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":12,"version":2}} +data: {"id":"evt_1cb8e4e83001mLlXLpo2HKa8RS","created":1794416463491,"type":"session.step.ended","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8dee31001m95G2PAuPMeqx0","finish":"tool-calls","rawFinish":"tool_calls","cost":0,"tokens":{"input":11624,"output":27,"reasoning":26,"cache":{"read":0,"write":0}},"snapshot":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","files":[]},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":13,"version":1}} +data: {"id":"evt_1cb8e4e850015bVjUeOiwgageq","created":1794416463493,"type":"session.usage.updated","data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","cost":0,"tokens":{"input":11624,"output":27,"reasoning":26,"cache":{"read":0,"write":0}}}} +data: {"id":"evt_1cb8e5102001c2YQLrLZEmiU9e","created":1794416464130,"type":"session.step.started","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","agent":"build","model":{"id":"work","providerID":"dgx"},"assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","snapshot":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","started":1794416463507},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":14,"version":1}} +data: {"id":"evt_1cb8e5107001Eq5UqGfyhUgvOM","created":1794416464135,"type":"session.reasoning.started","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":15,"version":1}} +data: {"id":"evt_1cb8e5170001EAjIKnxlxJzSAP","created":1794416464240,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":"Count entries:"}} +data: {"id":"evt_1cb8e51f5001iMXyRdH0Y52Y3q","created":1794416464373,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":" CLAUDE.md"}} +data: {"id":"evt_1cb8e525c001xGpd4ocCmTVIgi","created":1794416464477,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":", Gemfile, LICENSE, README"}} +data: {"id":"evt_1cb8e52cc001DIVZZfiCNoGOkf","created":1794416464588,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":".md, Rakefile, bench"}} +data: {"id":"evt_1cb8e5354001DUEwI7MouamMS9","created":1794416464724,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":", bin, dcb_event_store.gemspec,"}} +data: {"id":"evt_1cb8e53e2001CDEEr4NE5W9B7L","created":1794416464866,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":" docs, examples, lib, plan, scripts, test"}} +data: {"id":"evt_1cb8e54250016NXrAghEm3NllR","created":1794416464933,"type":"session.reasoning.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":" = 14."}} +data: {"id":"evt_1cb8e54260014I0tdC1J8b8Tib","created":1794416464934,"type":"session.text.started","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":16,"version":1}} +data: {"id":"evt_1cb8e5479001eBZhGsQixWaJM4","created":1794416465017,"type":"session.reasoning.ended","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"text":"Count entries: CLAUDE.md, Gemfile, LICENSE, README.md, Rakefile, bench, bin, dcb_event_store.gemspec, docs, examples, lib, plan, scripts, test = 14.","state":{"reasoningField":"reasoning_content"}},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":17,"version":1}} +data: {"id":"evt_1cb8e547c001qFBc2mJJD0H33t","created":1794416465020,"type":"session.text.delta","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"delta":"14"}} +data: {"id":"evt_1cb8e547c002xfIFtvCmnVVN8q","created":1794416465020,"type":"session.text.ended","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","ordinal":0,"text":"14"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":18,"version":1}} +data: {"id":"evt_1cb8e547f0011EdmAdhFv1qz8U","created":1794416465023,"type":"session.step.streamed","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":19,"version":1}} +data: {"id":"evt_1cb8e54890017QpBTYn9wfVobJ","created":1794416465033,"type":"session.step.ended","location":{"directory":"/home/agent/work/ruby-dcb"},"data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","assistantMessageID":"msg_1cb8e4e8c001r25R4wH8JiB79T","finish":"stop","rawFinish":"stop","cost":0,"tokens":{"input":113,"output":4,"reasoning":50,"cache":{"read":11619,"write":0}},"snapshot":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","files":[]},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":20,"version":1}} +data: {"id":"evt_1cb8e548b001ht2lbVzFmN8Yh4","created":1794416465035,"type":"session.usage.updated","data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs","cost":0,"tokens":{"input":11737,"output":31,"reasoning":76,"cache":{"read":11619,"write":0}}}} +data: {"id":"evt_1cb8e548d001ZGt9vvnNy2oONN","created":1794416465037,"type":"session.execution.succeeded","data":{"sessionID":"ses_e3472121affe4vLPUtwHxSd4xs"},"durable":{"aggregateID":"ses_e3472121affe4vLPUtwHxSd4xs","seq":21,"version":1}} diff --git a/docs/opencode-v2-port/sample-form-events.sse b/docs/opencode-v2-port/sample-form-events.sse new file mode 100644 index 000000000..44daa7eaa --- /dev/null +++ b/docs/opencode-v2-port/sample-form-events.sse @@ -0,0 +1,8 @@ +# Real question/form round trip captured from opencode 2.0.29 (2026-11-11). +# The agent's question tool produced form.created; the client replied POST +# /api/session/{sessionID}/form/{formID}/reply {answer:{q0:"Red"}} (204), +# which produced form.replied. Pending forms list at GET /api/form. +data: {"id": "evt_1cc2276a800237UM9FSxnG4vOI", "created": 1794426173096, "type": "form.created", "location": {"directory": "/tmp/permtest"}, "data": {"form": {"id": "frm_1cc2276a8001NEpUsRX6txMopI", "sessionID": "ses_e33dd939affeAMv6dl42tQZuE6", "title": "Questions", "metadata": {"kind": "question", "tool": {"messageID": "msg_1cc226c96001WIZFN1E9lr7a0U", "id": "call_8ad23cf181262162b94ffac5"}}, "fields": [{"key": "q0", "title": "Logo color", "description": "What color should the logo be?", "type": "string", "options": [{"value": "Red", "label": "Red", "description": "Use red for the logo."}, {"value": "Blue", "label": "Blue", "description": "Use blue for the logo."}], "custom": true}]}}} + +data: {"id": "evt_1cc251cf5001H9Rnt8a80qP4zJ", "created": 1794426346741, "type": "form.replied", "location": {"directory": "/tmp/permtest"}, "data": {"id": "frm_1cc2276a8001NEpUsRX6txMopI", "sessionID": "ses_e33dd939affeAMv6dl42tQZuE6", "answer": {"q0": "Red"}}} + diff --git a/docs/opencode-v2-port/sample-messages.json b/docs/opencode-v2-port/sample-messages.json new file mode 100644 index 000000000..8538d0a7e --- /dev/null +++ b/docs/opencode-v2-port/sample-messages.json @@ -0,0 +1 @@ +{"data":[{"id":"msg_1cb8e548d001ZGt9vvnNy2oONN","time":{"created":1794416465037},"type":"idle","outcome":"succeeded"},{"id":"msg_1cb8e4e8c001r25R4wH8JiB79T","time":{"created":1794416463507,"streamed":1794416465023,"completed":1794416465033},"type":"assistant","agent":"build","model":{"id":"work","providerID":"dgx"},"content":[{"type":"reasoning","text":"Count entries: CLAUDE.md, Gemfile, LICENSE, README.md, Rakefile, bench, bin, dcb_event_store.gemspec, docs, examples, lib, plan, scripts, test = 14.","state":{"reasoningField":"reasoning_content"},"time":{"created":1794416464135,"completed":1794416465017}},{"type":"text","text":"14"}],"snapshot":{"start":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","end":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","files":[]},"finish":"stop","rawFinish":"stop","cost":0,"tokens":{"input":113,"output":4,"reasoning":50,"cache":{"read":11619,"write":0}}},{"id":"msg_1cb8dee31001m95G2PAuPMeqx0","time":{"created":1794416438841,"streamed":1794416463445,"completed":1794416463491},"type":"assistant","agent":"build","model":{"id":"work","providerID":"dgx"},"content":[{"type":"reasoning","text":"The user wants me to run `ls` in the repo and reply with the number of entries. Just do it.","state":{"reasoningField":"reasoning_content"},"time":{"created":1794416462581,"completed":1794416463440}},{"type":"tool","id":"call_792d2014cdc9167a91e4dc28","name":"shell","executed":false,"state":{"status":"completed","input":{"command":"ls"},"content":[{"type":"text","text":"CLAUDE.md\nGemfile\nLICENSE\nREADME.md\nRakefile\nbench\nbin\ndcb_event_store.gemspec\ndocs\nexamples\nlib\nplan\nscripts\ntest\n"}],"metadata":{"status":"completed","truncated":false,"exit":0}},"time":{"created":1794416463268,"ran":1794416463443,"completed":1794416463484}}],"snapshot":{"start":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","end":"bc14945f65f4cf569b3a1e9b95d2c7a9fe80fb38","files":[]},"finish":"tool-calls","rawFinish":"tool_calls","cost":0,"tokens":{"input":11624,"output":27,"reasoning":26,"cache":{"read":0,"write":0}}},{"id":"msg_1cb8dee03001RpXshDbNwev1KM","time":{"created":1794416438825},"text":"Run `ls` in the repo with your shell tool, then reply with the number of entries. Nothing else.","type":"user"}],"cursor":{"previous":"eyJpZCI6Im1zZ18xY2I4ZTU0OGQwMDFaR3Q5dnZuTnkyb09OTiIsIm9yZGVyIjoiZGVzYyIsImRpcmVjdGlvbiI6InByZXZpb3VzIn0","next":"eyJpZCI6Im1zZ18xY2I4ZGVlMDMwMDFScFhzaERiTndldjFLTSIsIm9yZGVyIjoiZGVzYyIsImRpcmVjdGlvbiI6Im5leHQifQ"}} \ No newline at end of file diff --git a/docs/opencode-v2-port/sample-permission-events.sse b/docs/opencode-v2-port/sample-permission-events.sse new file mode 100644 index 000000000..e5a023d90 --- /dev/null +++ b/docs/opencode-v2-port/sample-permission-events.sse @@ -0,0 +1,8 @@ +# Real permission round trip captured from opencode 2.0.29 (2026-11-11). +# Server config had permission.bash=ask; the client replied POST +# /api/session/{sessionID}/permission/{requestID}/reply {decision:"once"}, +# which answered 204 and produced permission.replied. +data: {"id": "evt_1cc21ae59002Ys96WY7l68O8bQ", "created": 1794426121817, "type": "permission.asked", "location": {"directory": "/tmp/permtest"}, "data": {"id": "per_1cc21ae590015nhfeancsYTtxD", "sessionID": "ses_e33de57cfffemGtguapj6e1ldW", "action": "shell", "resources": ["echo hello"], "save": ["echo *"], "source": {"type": "tool", "messageID": "msg_1cc21a9ef001BOni6Md9BkOnhW", "id": "call_f4c8d4df29be433fa503f14c"}}} + +data: {"id": "evt_1cc2216fe001DclTeQFNZD0JeY", "created": 1794426148606, "type": "permission.replied", "location": {"directory": "/tmp/permtest"}, "data": {"sessionID": "ses_e33de57cfffemGtguapj6e1ldW", "requestID": "per_1cc21ae590015nhfeancsYTtxD", "reply": "once"}} + diff --git a/docs/opencode-v2-port/sample-session.json b/docs/opencode-v2-port/sample-session.json new file mode 100644 index 000000000..f55a4da1f --- /dev/null +++ b/docs/opencode-v2-port/sample-session.json @@ -0,0 +1 @@ +{"data":{"id":"ses_e3472121affe4vLPUtwHxSd4xs","projectID":"9d0a77c481d8da42e86ea6a51203ff1d08a43f01","cost":0,"tokens":{"input":11737,"output":31,"reasoning":76,"cache":{"read":11619,"write":0}},"outcome":"succeeded","time":{"created":1794416438769,"updated":1794416465033,"idle":1794416465037},"title":"probe","location":{"directory":"/home/agent/work/ruby-dcb"}}} \ No newline at end of file diff --git a/scripts/v2-live-check.ts b/scripts/v2-live-check.ts new file mode 100644 index 000000000..2eec8d551 --- /dev/null +++ b/scripts/v2-live-check.ts @@ -0,0 +1,99 @@ +// Drives the 2.x adapter (src/lib/sdk-v2.ts) against a real opencode 2.0.29 +// server, end to end: detection, sessions, prompt, the live event stream, and +// the mapped transcript. +// +// Usage: node scripts/v2-live-check.ts [directory] +import { createV2Client, V2EventTranslator } from "../src/lib/sdk-v2.ts" +import { detectServerMajor } from "../src/lib/server-version.ts" +import type { Event } from "../src/lib/sdk.ts" + +const base = (process.argv[2] ?? "http://127.0.0.1:4196").replace(/\/+$/, "") +const auth = { username: process.argv[3] ?? "opencode", password: process.argv[4] ?? "" } +const directory = process.argv[5] + +function streamEvents(signal: AbortSignal): AsyncGenerator { + return (async function* () { + const translator = new V2EventTranslator() + const response = await fetch(`${base}/api/event`, { signal, headers: { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` } }) + if (!response.ok || !response.body) throw new Error(`event stream ${response.status}`) + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + for (;;) { + const { done, value } = await reader.read() + if (done) return + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + for (const line of frame.split("\n")) { + if (!line.startsWith("data: ")) continue + try { + yield* translator.translate(JSON.parse(line.slice(6)) as never) + } catch { + // ignore malformed frames + } + } + } + } + })() +} + +const major = await detectServerMajor({ baseUrl: base, auth }, { force: true }) +console.log("detected:", major) +if (major !== "v2") { + console.log("FAIL: not a 2.x server") + process.exit(1) +} + +const controller = new AbortController() +const client = createV2Client({ baseUrl: base, directory, auth }, (signal) => streamEvents(signal ?? controller.signal)) + +console.log("health:", JSON.stringify(await client.global.health())) + +const sessions = await client.session.list({ roots: true, limit: 5 }) +console.log(`sessions: ${sessions.length}; first:`, sessions[0]?.id, JSON.stringify(sessions[0]?.title)) + +const created = await client.session.create({ title: `live-check ${new Date().toISOString()}` }) +console.log("created:", created.id, "directory:", created.directory) + +const seen: string[] = [] +const pump = (async () => { + for await (const event of client.global.events(controller.signal)) { + if (event.type === "message.part.updated") { + const part = (event.properties as { part: { type: string; text?: string; tool?: string; state?: { status?: string } } }).part + seen.push(`part:${part.type}${part.tool ? `/${part.tool}` : ""}:${part.state?.status ?? ""}:${(part.text ?? "").length}`) + } else if (event.type === "message.updated") { + const info = (event.properties as { info: { role: string } }).info + seen.push(`message:${info.role}`) + } else { + seen.push(event.type) + } + } +})().catch((err) => console.log("stream ended:", String(err).slice(0, 80))) + +await client.session.prompt(created.id, { parts: [{ type: "text", text: "Reply with exactly OK, no tools." }] }) + +let done = false +for (let i = 0; i < 160 && !done; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const messages = await client.session.messages(created.id, { limit: 5 }) + const assistant = messages.find((m) => m.info.role === "assistant") + const textPart = assistant?.parts.find((p) => p.type === "text") + if (assistant?.info.finish && textPart) { + console.log("transcript roles:", messages.map((m) => m.info.role).join(",")) + console.log("assistant parts:", assistant.parts.map((p) => `${p.type}${p.tool ? `(${p.tool})` : ""}`).join(", ")) + console.log("assistant text:", JSON.stringify(textPart.text)) + console.log("assistant model:", assistant.info.modelID, assistant.info.providerID, "finish:", assistant.info.finish) + done = true + } +} + +const idle = seen.filter((s) => s === "session.status").length +console.log("stream event kinds:", [...new Set(seen)].join(" | ")) +console.log(`status transitions seen: ${idle}`) +controller.abort() +await pump +await client.session.delete(created.id) +console.log(done ? "OK" : "FAIL: no completed assistant reply") +process.exit(done ? 0 : 1) diff --git a/scripts/v2-live-detect.ts b/scripts/v2-live-detect.ts new file mode 100644 index 000000000..2d233dde9 --- /dev/null +++ b/scripts/v2-live-detect.ts @@ -0,0 +1,22 @@ +// Confirms the per-connection version probe against real servers of both majors. +// Usage: node scripts/v2-live-detect.ts +import { detectServerMajor, clearServerMajorCache } from "../src/lib/server-version.ts" + +const base = (process.argv[2] ?? "http://127.0.0.1:4196").replace(/\/+$/, "") +const auth = { username: process.argv[3] ?? "opencode", password: process.argv[4] ?? "" } +const expected = process.argv[5] as "v1" | "v2" | undefined + +const probe = await fetch(`${base}/api/info`, { + headers: { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` }, +}) +console.log(`GET ${base}/api/info -> ${probe.status} ${probe.headers.get("content-type")}`) + +clearServerMajorCache() +const major = await detectServerMajor({ baseUrl: base, auth }, { force: true }) +console.log("detected:", major, expected ? `(expected ${expected})` : "") + +if (expected && major !== expected) { + console.log("FAIL") + process.exit(1) +} +console.log("OK") diff --git a/scripts/v2-live-editevents.ts b/scripts/v2-live-editevents.ts new file mode 100644 index 000000000..89e488555 --- /dev/null +++ b/scripts/v2-live-editevents.ts @@ -0,0 +1,83 @@ +// Records the raw 2.x event stream across an "edit an old message" sequence: +// stage the revert, then re-prompt. The app's transcript is driven only by +// these events, so this shows what the UI can possibly see. +const base = process.argv[2] ?? "http://127.0.0.1:4196" +const auth = "Basic " + Buffer.from(`opencode:${process.argv[3] ?? "forge-test-pass"}`).toString("base64") +const dir = process.argv[4] ?? "/tmp/oc2-home" +const H = { Authorization: auth } + +const res = await fetch(`${base}/api/event?location%5Bdirectory%5D=${encodeURIComponent(dir)}`, { headers: H }) +const reader = (res.body as ReadableStream).getReader() +const decoder = new TextDecoder() +let buffer = "" +const events: Record[] = [] +const pump = (async () => { + for (;;) { + const { done, value } = await reader.read() + if (done) return + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + for (const line of frame.split("\n")) { + if (!line.startsWith("data: ")) continue + try { + events.push(JSON.parse(line.slice(6))) + } catch { + /* heartbeat/comment lines are not data */ + } + } + } + } +})() + +async function call(method: string, path: string, body?: unknown) { + const r = await fetch(base + path, { + method, + headers: { ...H, "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) + const t = await r.text() + try { + return JSON.parse(t) + } catch { + return t + } +} +const waitIdle = async (id: string) => { + for (;;) { + const a = await call("GET", "/api/session/active") + if (!(a.data ?? a)?.[id as string]) return + await new Promise((r) => setTimeout(r, 600)) + } +} + +const created = await call("POST", "/api/session", { title: `edit-flow ${Date.now()}`, location: { directory: dir } }) +const id = String((created as { data?: { id?: string } }).data?.id ?? (created as { id?: string }).id) +console.log("session:", id) + +await call("POST", `/api/session/${id}/prompt`, { text: "Say OK. No tools." }) +await waitIdle(String(id)) +const first = ((await call("GET", `/api/session/${id}/message?order=asc`)) as { data: { type: string; id: string }[] }).data.find( + (m) => m.type === "user", +)! +console.log("\n=== events during: stage revert of", first.id) +events.length = 0 +await call("POST", `/api/session/${id}/revert/stage`, { messageID: first.id }) +await new Promise((r) => setTimeout(r, 1500)) +console.log(events.map((e) => JSON.stringify({ t: (e as { type?: string }).type, id: (e as { data?: Record })?.data?.sessionID, keys: Object.keys((e as { data?: object }).data ?? {}) })).join("\n")) + +console.log("\n=== events during: re-prompt with revert staged") +events.length = 0 +await call("POST", `/api/session/${id}/prompt`, { text: "Say DONE. No tools." }) +await waitIdle(id) +await new Promise((r) => setTimeout(r, 1500)) +for (const e of events) { + const data = (e as { data?: Record }).data ?? {} + const t = (e as { type?: string }).type ?? "" + if (/inbox|revert/.test(t)) console.log(JSON.stringify(e).slice(0, 600)) + else if (/step.started|execution.succeeded/.test(t)) console.log(t) +} +console.log("\nsession:", JSON.stringify(((await call("GET", `/api/session/${id}`)) as { data?: any }).data?.revert ?? null).slice(0, 120)) +reader.cancel() +process.exit(0) diff --git a/scripts/v2-live-editflow.ts b/scripts/v2-live-editflow.ts new file mode 100644 index 000000000..6549d3fdd --- /dev/null +++ b/scripts/v2-live-editflow.ts @@ -0,0 +1,106 @@ +// Live check for the "edit a sent message" flow on a real 2.x server: the +// pending-revert marker must arrive over the stream (staged -> committed), +// because 2.x commits it itself when the re-sent prompt lands. +// Usage: node scripts/v2-live-editflow.ts [directory] +import { createV2Client, V2EventTranslator } from "../src/lib/sdk-v2.ts" +import type { Event } from "../src/lib/sdk.ts" + +const base = (process.argv[2] ?? "http://127.0.0.1:4196").replace(/\/+$/, "") +const auth = { username: process.argv[3] ?? "opencode", password: process.argv[4] ?? "forge-test-pass" } +const directory = process.argv[5] ?? process.cwd() + +const controller = new AbortController() +function stream(signal: AbortSignal): AsyncGenerator { + return (async function* () { + const translator = new V2EventTranslator() + const response = await fetch(`${base}/api/event`, { signal, headers: { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` } }) + const reader = (response.body as ReadableStream).getReader() + const decoder = new TextDecoder() + let buffer = "" + for (;;) { + const { done, value } = await reader.read() + if (done) return + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + for (const line of frame.split("\n")) { + if (!line.startsWith("data: ")) continue + try { + yield* translator.translate(JSON.parse(line.slice(6)) as never) + } catch { + // ignore + } + } + } + } + })() +} + +const client = createV2Client({ baseUrl: base, directory, auth }, (signal) => stream(signal ?? controller.signal)) +const seen: Event[] = [] +void (async () => { + for await (const event of client.global.events(controller.signal)) seen.push(event) +})().catch(() => {}) + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +const revertEvents = () => seen.filter((e) => e.type === "session.revert") +const settle = async (sessionID: string, ms = 180_000) => { + const started = Date.now() + for (;;) { + const list = await client.session.messages(sessionID) + const last = list[list.length - 1] + if (last?.info.role === "assistant" && last.info.finish) return list + if (Date.now() - started > ms) throw new Error("session never settled") + await wait(1000) + } +} + +const session = await client.session.create({ title: `edit-flow ${Date.now()}` }) +console.log("session:", session.id) +await client.session.prompt(session.id, { parts: [{ type: "text", text: "Say OK. Do not use tools." }] }) +const first = await settle(session.id) +console.log("transcript:", first.map((m) => `${m.info.role}:${(m.parts[0]?.text ?? "").slice(0, 8)}`).join(", ")) + +const userMessage = first.find((m) => m.info.role === "user") +if (!userMessage) { + console.log("FAIL: no user message to edit") + process.exit(1) +} + +// 1) "Edit message" stages the revert. +await wait(800) +seen.length = 0 +await client.session.revert(session.id, userMessage.info.id) +await wait(1500) +const staged = revertEvents() +console.log("after revert():", JSON.stringify(staged.map((e) => ({ ...e.properties, revert: (e.properties as { revert?: { messageID: string } }).revert })))) +if (!staged.some((e) => (e.properties as { revert?: { messageID: string } }).revert?.messageID === userMessage.info.id)) { + console.log("FAIL: staged revert never reached the stream") + process.exit(1) +} + +// 2) The user edits and re-sends. 2.x commits the revert without being asked. +const before = revertEvents().length +await client.session.prompt(session.id, { parts: [{ type: "text", text: "Say DONE. Do not use tools." }] }) +const after = await settle(session.id) +await wait(1200) +const committed = revertEvents().slice(before) +console.log("after re-send:", JSON.stringify(committed.map((e) => ({ id: e.type, ...e.properties })))) +const cleared = committed.find((e) => (e.properties as { revert?: unknown }).revert === undefined) +if (!cleared) { + console.log("FAIL: the committed revert never cleared the marker — the banner would stay") + process.exit(1) +} +if ((cleared.properties as { droppedFrom?: string }).droppedFrom !== userMessage.info.id) { + console.log("FAIL: commit did not name the first dropped message") + process.exit(1) +} +console.log("new transcript:", after.map((m) => `${m.info.role}:${(m.parts[0]?.text ?? "").slice(0, 8)}`).join(", ")) +if (after.some((m) => m.info.id === userMessage.info.id)) { + console.log("FAIL: the edited message is still in the transcript") + process.exit(1) +} +controller.abort() +console.log("OK") +process.exit(0) diff --git a/scripts/v2-live-interrupt.ts b/scripts/v2-live-interrupt.ts new file mode 100644 index 000000000..cb19fc8e9 --- /dev/null +++ b/scripts/v2-live-interrupt.ts @@ -0,0 +1,71 @@ +// Interrupt + paginated-history checks against a real 2.x server. +// Usage: node scripts/v2-live-interrupt.ts [directory] +import { createV2Client, V2EventTranslator } from "../src/lib/sdk-v2.ts" +import type { Event } from "../src/lib/sdk.ts" + +const base = (process.argv[2] ?? "http://127.0.0.1:4196").replace(/\/+$/, "") +const auth = { username: process.argv[3] ?? "opencode", password: process.argv[4] ?? "forge-test-pass" } +const directory = process.argv[5] + +const controller = new AbortController() +function stream(signal: AbortSignal): AsyncGenerator { + return (async function* () { + const translator = new V2EventTranslator() + const response = await fetch(`${base}/api/event`, { signal, headers: { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` } }) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffer = "" + for (;;) { + const { done, value } = await reader.read() + if (done) return + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + for (const line of frame.split("\n")) { + if (!line.startsWith("data: ")) continue + try { + yield* translator.translate(JSON.parse(line.slice(6)) as never) + } catch { + // ignore + } + } + } + } + })() +} + +const client = createV2Client({ baseUrl: base, directory, auth }, (signal) => stream(signal ?? controller.signal)) +const statuses: string[] = [] +void (async () => { + for await (const event of client.global.events(controller.signal)) { + if (event.type === "session.status") statuses.push((event.properties as { status: { type: string } }).status.type) + if (event.type === "session.error") console.log("session.error:", JSON.stringify(event.properties)) + } +})().catch(() => {}) + +const session = await client.session.create({ title: `interrupt-check ${Date.now()}` }) +console.log("session:", session.id) +await client.session.prompt(session.id, { + parts: [{ type: "text", text: "Count from 1 to 30 out loud, one number per line, in your reply text. Do not use tools." }], +}) + +await new Promise((resolve) => setTimeout(resolve, 2500)) +console.log("aborting…", await client.session.abort(session.id)) +await new Promise((resolve) => setTimeout(resolve, 3000)) +console.log("status events:", statuses.join(",")) + +const messages = await client.session.messages(session.id, { limit: 5 }) +console.log("roles:", messages.map((m) => m.info.role).join(",")) +const assistant = messages.find((m) => m.info.role === "assistant") +if (assistant) { + console.log("parts:", assistant.parts.map((p) => `${p.type}:${(p.text ?? "").length}`).join(", ")) + console.log("finish:", assistant.info.finish, "completed:", Boolean(assistant.info.time.completed)) +} + +const diff = await client.session.diff(session.id) +console.log("diff entries:", diff.length) + +controller.abort() +console.log(statuses.includes("idle") ? "OK" : "FAIL: never went idle after abort") +process.exit(statuses.includes("idle") ? 0 : 1) diff --git a/scripts/v2-live-permission.ts b/scripts/v2-live-permission.ts new file mode 100644 index 000000000..bd2ec3f25 --- /dev/null +++ b/scripts/v2-live-permission.ts @@ -0,0 +1,104 @@ +// Permission + interrupt round trip against a REAL opencode 2.x server. +// Run against a server whose config asks before running shell commands. +// +// Usage: node scripts/v2-live-permission.ts +import { createV2Client, V2EventTranslator } from "../src/lib/sdk-v2.ts" +import { detectServerMajor } from "../src/lib/server-version.ts" +import type { Event } from "../src/lib/sdk.ts" + +const base = (process.argv[2] ?? "http://127.0.0.1:4197").replace(/\/+$/, "") +const auth = { username: process.argv[3] ?? "opencode", password: process.argv[4] ?? "forge-test-pass" } +const directory = process.argv[5] ?? "/tmp/permtest" + +const controller = new AbortController() + +function stream(signal: AbortSignal): AsyncGenerator { + return (async function* () { + const translator = new V2EventTranslator() + const response = await fetch(`${base}/api/event`, { signal, headers: { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` } }) + if (!response.ok || !response.body) throw new Error(`event stream ${response.status}`) + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + for (;;) { + const { done, value } = await reader.read() + if (done) return + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + for (const line of frame.split("\n")) { + if (!line.startsWith("data: ")) continue + try { + yield* translator.translate(JSON.parse(line.slice(6)) as never) + } catch { + // ignore + } + } + } + } + })() +} + +console.log("detected:", await detectServerMajor({ baseUrl: base, auth }, { force: true })) +const client = createV2Client({ baseUrl: base, directory, auth }, (signal) => stream(signal ?? controller.signal)) + +const seen: Event[] = [] +void (async () => { + for await (const event of client.global.events(controller.signal)) seen.push(event) +})().catch(() => {}) + +const session = await client.session.create({ title: `permission-check ${Date.now()}` }) +console.log("session:", session.id) +await client.session.prompt(session.id, { + parts: [{ type: "text", text: "Use your shell tool to run: echo hello-from-permission-check. Then say DONE." }], +}) + +const waited = (ms: number) => { + const started = Date.now() + return () => Date.now() - started < ms +} + +// Wait for the permission request the server raises when it wants to run a tool. +const pending = await (async () => { + const still = waited(60_000) + for (;;) { + const list = await client.permission.list() + if (list.length) return list[0] + if (!still()) throw new Error("no permission request within 60s") + await new Promise((resolve) => setTimeout(resolve, 500)) + } +})() + +console.log("asked:", pending.id, "action:", pending.permission, "resources:", JSON.stringify(pending.patterns)) +console.log("replying once…") +await client.permission.reply(pending.id, "once") + +const still = waited(90_000) +for (;;) { + // A single run can raise more than one request (the agent re-asks after an + // interrupt or for a second command) — keep answering whatever is pending. + const more = await client.permission.list() + for (const request of more) { + console.log("answering follow-up:", request.id) + await client.permission.reply(request.id, "once") + } + const messages = await client.session.messages(session.id, { limit: 10 }) + const tool = messages.flatMap((m) => m.parts).find((p) => p.type === "tool") + const text = messages.flatMap((m) => m.parts).find((p) => p.type === "text" && p.text) + if (tool?.state?.status === "completed" && text) { + console.log("tool:", tool.tool, "state:", tool.state?.status, "output:", JSON.stringify(String(tool.state.output).trim().slice(0, 60))) + console.log("text:", JSON.stringify(String(text.text).slice(0, 60))) + break + } + if (!still()) { + console.log("FAIL: tool did not complete", JSON.stringify(tool?.state)) + process.exit(1) + } + await new Promise((resolve) => setTimeout(resolve, 1000)) +} + +console.log("permission events:", seen.filter((e) => e.type.startsWith("permission")).map((e) => e.type).join(",")) +controller.abort() +console.log("OK") +process.exit(0) diff --git a/scripts/v2-live-question.ts b/scripts/v2-live-question.ts new file mode 100644 index 000000000..c23d013fe --- /dev/null +++ b/scripts/v2-live-question.ts @@ -0,0 +1,99 @@ +// Question/form round trip against a REAL opencode 2.x server. +// Usage: node scripts/v2-live-question.ts [directory] +import { createV2Client, V2EventTranslator } from "../src/lib/sdk-v2.ts" +import type { Event } from "../src/lib/sdk.ts" + +const base = (process.argv[2] ?? "http://127.0.0.1:4196").replace(/\/+$/, "") +const auth = { username: process.argv[3] ?? "opencode", password: process.argv[4] ?? "forge-test-pass" } +const directory = process.argv[5] + +const controller = new AbortController() +function stream(signal: AbortSignal): AsyncGenerator { + return (async function* () { + const translator = new V2EventTranslator() + const response = await fetch(`${base}/api/event`, { signal, headers: { Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}` } }) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffer = "" + for (;;) { + const { done, value } = await reader.read() + if (done) return + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + for (const line of frame.split("\n")) { + if (!line.startsWith("data: ")) continue + try { + yield* translator.translate(JSON.parse(line.slice(6)) as never) + } catch { + // ignore + } + } + } + } + })() +} + +const client = createV2Client({ baseUrl: base, directory, auth }, (signal) => stream(signal ?? controller.signal)) +const asked: Event[] = [] +const replied: string[] = [] +void (async () => { + for await (const event of client.global.events(controller.signal)) { + if (event.type === "question.asked") asked.push(event) + if (event.type === "question.replied") replied.push("replied") + } +})().catch(() => {}) + +const session = await client.session.create({ title: `question-check ${Date.now()}` }) +console.log("session:", session.id) +await client.session.prompt(session.id, { + parts: [{ type: "text", text: "Use your question tool to ask me ONE question: which fruit should the app icon be — apple or banana? Wait for my answer and then say thanks." }], +}) + +const started = Date.now() +let pending: Awaited>[number] | undefined +while (Date.now() - started < 60_000) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const list = await client.question.list() + if (list.length) { + pending = list[0] + break + } +} + +if (!pending) { + console.log("FAIL: no form/question was raised within 60s") + process.exit(1) +} + +console.log("asked:", pending.id, "header:", JSON.stringify(pending.questions[0]?.header), "options:", JSON.stringify(pending.questions[0]?.options?.map((o) => o.label))) +console.log("answered from event stream:", asked.length, "so far") +await client.question.reply(pending.id, [["Apple"]]) +console.log("replied; waiting for the run to finish…") + +const deadline = Date.now() + 90_000 +for (;;) { + // Any further form the agent raises while running must also be answered. + const more = await client.question.list() + for (const follow of more) { + console.log("answering follow-up form:", follow.id) + await client.question.reply(follow.id, [["Apple"]]) + } + const messages = await client.session.messages(session.id, { limit: 10 }) + const final = messages.find((m) => m.info.role === "assistant" && m.parts.some((p) => p.type === "text")) + if (final) { + console.log("assistant finish:", final.info.finish, "text:", JSON.stringify((final.parts.find((p) => p.type === "text")?.text ?? "").slice(0, 80))) + console.log("assistant messages:", messages.filter((m) => m.info.role === "assistant").length) + break + } + if (Date.now() > deadline) { + console.log("FAIL: run did not finish after answering") + process.exit(1) + } + await new Promise((resolve) => setTimeout(resolve, 1000)) +} +console.log("question.replied events:", replied.length) +controller.abort() +console.log("OK") +process.exit(0) diff --git a/scripts/v2-live-revert.ts b/scripts/v2-live-revert.ts new file mode 100644 index 000000000..671b302ac --- /dev/null +++ b/scripts/v2-live-revert.ts @@ -0,0 +1,80 @@ +// What does 2.x do when an edited message is re-sent with a revert still +// staged? The app stages (revert), edits, then prompts — 2.x has a third +// operation (revert/commit) the 1.x flow never needed. +const base = process.argv[2] ?? "http://127.0.0.1:4196" +const auth = "Basic " + Buffer.from(`${process.argv[3] ?? "opencode"}:${process.argv[4] ?? "forge-test-pass"}`).toString("base64") +const dir = process.argv[5] ?? "/tmp/oc2-home" + +const q = (p: string): string => `${base}${p}${p.includes("?") ? "&" : "?"}location%5Bdirectory%5D=${encodeURIComponent(dir)}` +const H = { Authorization: auth } + +async function call(method: string, path: string, body?: unknown): Promise<{ status: number; json: AnyBody }> { + const res = await fetch(q(path), { + method, + headers: { ...H, "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + return { status: res.status, json: safe(text) as AnyBody } +} +type AnyBody = Record + +function safe(t: string): unknown { try { return JSON.parse(t) } catch { return t.slice(0, 80) } } + +const created = await call("POST", "/api/session", { title: `revert-probe ${Date.now()}`, location: { directory: dir } }) +const id = created.json?.data?.id ?? created.json?.id +if (!id) { console.log("create failed:", JSON.stringify(created)); process.exit(1) } +console.log("session:", id) + +function waitIdle(minutes = 1) { + return new Promise((resolve) => { + const started = Date.now() + const tick = async (): Promise => { + const active = await call("GET", "/api/session/active") + if (!active.json?.data?.[id]) return resolve(true) + if (Date.now() - started > minutes * 60_000) return resolve(false) + setTimeout(tick, 700) + } + tick() + }) +} + +async function prompt(text: string): Promise { + const r = await call("POST", `/api/session/${id}/prompt`, { text }) + console.log(` prompt -> HTTP ${r.status}`) + await waitIdle() +} + +async function dump(label: string): Promise { + const { json } = await call("GET", `/api/session/${id}/message?order=asc`) + const rows = (json?.data ?? json ?? []).map((m: any) => ({ + type: m.type, + id: m.id.slice(-6), + outcome: m.outcome ?? undefined, + revert: m.revert ?? undefined, + parts: (m.content ?? []).map((c: any) => `${c.type}${c.tool ? `:${c.tool}` : ""}${c.state ? `/${c.state.status}` : ""}`), + text: (m.text ?? "").slice(0, 30), + })) + console.log(`${label}\n ` + JSON.stringify(rows)) + const s = await call("GET", `/api/session/${id}`) + console.log(" session.revert:", JSON.stringify(s.json?.data?.revert ?? null)) +} + +await prompt("Say OK. Do not use tools.") +await dump("after first prompt:") + +const first = (await call("GET", `/api/session/${id}/message?order=asc`)).json?.data.find((m: any) => m.type === "user") +console.log("staging revert of user message", first.id) +console.log(" stage ->", JSON.stringify(await call("POST", `/api/session/${id}/revert/stage`, { messageID: first.id }))) +await dump("after stage:") + +await prompt("Say DONE. Do not use tools.") +await dump("after re-prompt (revert still staged):") + +console.log("commit ->", JSON.stringify(await call("POST", `/api/session/${id}/revert/commit`))) +await dump("after commit:") + +const cleared = await call("DELETE", `/api/session/${id}/revert`) +console.log("clear ->", cleared.status) +await dump("after clear:") +process.exit(0) diff --git a/src/lib/api-error.ts b/src/lib/api-error.ts index 97f1d9679..2f0476430 100644 --- a/src/lib/api-error.ts +++ b/src/lib/api-error.ts @@ -8,6 +8,23 @@ // server is briefly unreachable" — a 401 got treated like any transient // failure and retried forever (see events.ts's reconnect loop / issue #76). +// Shared API error types. Kept next to ApiAuthError so both sdk.ts (1.x) and +// sdk-v2.ts (2.x) throw the *same* classes — call sites check +// `err instanceof ApiError` (see sessions.ts revertToMessage) and must see one +// identity, not one class per client. + +/** Thrown for a non-2xx response. Carries the HTTP status so callers can + * distinguish e.g. 404 (older server, endpoint missing) from other failures + * without parsing the message string. */ +export class ApiError extends Error { + readonly status: number + constructor(status: number, message: string) { + super(message) + this.name = "ApiError" + this.status = status + } +} + /** Thrown by sdk.ts's request()/events() when the server responds 401/403, * so call sites can distinguish "bad credentials" from any other failure * (network error, 5xx, timeout) instead of catching a generic Error. */ @@ -28,7 +45,7 @@ export function isAuthStatus(status: number): boolean { /** Build the right error type for a failed HTTP response. */ export function apiErrorFor(status: number, message: string): Error { - return isAuthStatus(status) ? new ApiAuthError(status, message) : new Error(message) + return isAuthStatus(status) ? new ApiAuthError(status, message) : new ApiError(status, message) } /** Type guard for call sites (e.g. the SSE reconnect loop) that need to branch diff --git a/src/lib/diagnostics-classify.test.ts b/src/lib/diagnostics-classify.test.ts index 273dd8a7e..fc597e7ca 100644 --- a/src/lib/diagnostics-classify.test.ts +++ b/src/lib/diagnostics-classify.test.ts @@ -215,3 +215,32 @@ test("server-unreachable adds MagicDNS hint only for hostnames, not IPs", () => assert.match(hostR.summary, /MagicDNS/) assert.doesNotMatch(ipR.summary, /MagicDNS/) }) + +// ---------------- the 200-is-not-JSON case ---------------- + +test("a 200 whose body is HTML is not reported as a working API", () => { + const r = classify(okUrl, probe({ ok: true, status: 200, contentType: "text/html" }), probe({ ok: true }), probe({ ok: true })) + assert.equal(r.classification, "health-failed") + assert.match(r.summary, /instead of JSON/) +}) + +test("that summary points at the route actually probed", () => { + const r = classify(okUrl, probe({ ok: false, status: 500 }), probe({ ok: true }), probe({ ok: true }), { + major: "v2", + healthPath: "/api/info", + }) + assert.match(r.summary, /\/api\/info/) +}) + +test("JSON answers keep classifying as before", () => { + const healthy = probe({ ok: true, status: 200, contentType: "application/json", version: "2.0.29" }) + assert.equal(classify(okUrl, healthy, probe({ ok: true }), probe({ ok: true })).classification, "ok") + const denied = classify( + okUrl, + probe({ ok: false, status: 401, contentType: "application/json", authProvided: true }), + probe({ ok: true }), + probe({ ok: true }), + { major: "v2", healthPath: "/api/info" }, + ) + assert.equal(denied.classification, "auth-failed") +}) diff --git a/src/lib/diagnostics-classify.ts b/src/lib/diagnostics-classify.ts index 2cc7e9362..b08caaec9 100644 --- a/src/lib/diagnostics-classify.ts +++ b/src/lib/diagnostics-classify.ts @@ -23,6 +23,12 @@ export interface ProbeAttempt { error?: string errorCause?: string authProvided?: boolean + // Content type of a 2xx/4xx HTTP answer, when the probe read it. This is the + // only reliable way to tell an API response from the web-app HTML a 1.x or + // 2.x server serves for paths it does not implement. + contentType?: string + // The `version` field, for a JSON /api/info answer. + version?: string } export interface ParsedUrl { @@ -50,10 +56,28 @@ export function classify( health: ProbeAttempt, internet: ProbeAttempt, root: ProbeAttempt, + meta: { major?: "v1" | "v2"; healthPath?: string } = {}, ): { classification: Classification; summary: string } { + const healthPath = meta.healthPath ?? "/global/health" if (!parsed.valid) { return { classification: "malformed-url", summary: "The server URL could not be parsed. Check for typos or extra characters." } } + // A 200 that carries the web app instead of JSON is not a working API. Both + // majors serve their UI for any path they do not implement (verified on + // 1.18.32 and 2.0.29: `/global/health` answers 200 + HTML on a 2.x server), + // so "200 OK" on its own used to report a server that was never answering + // API calls as healthy. This is checked before the `ok` short-circuit so no + // caller can pass an HTML answer and have it read as success. + const answersHtml = health.contentType !== undefined && !health.contentType.includes("application/json") && (health.status ?? 0) < 400 + if (answersHtml) { + return { + classification: "health-failed", + summary: + `The server answered ${healthPath} with ${health.contentType} instead of JSON, so no opencode API is listening at ` + + `${parsed.host}:${parsed.port}. That HTML is a web app or a proxy page, not an API response: check the port (opencode ` + + "listens on 4096 by default), the path, or whether something else answers on that port.", + } + } if (health.ok) { return { classification: "ok", summary: "Health endpoint responded — connection actually works now." } } @@ -106,7 +130,10 @@ export function classify( // probe (captive portal, no WAN but Tailscale LAN still up, etc.) must not // override it and misreport a reachable server as "no internet". if (root.ok) { - return { classification: "health-failed", summary: `Server is reachable but /global/health failed (HTTP ${health.status ?? "error"}). Likely a wrong path or an old server version.` } + return { + classification: "health-failed", + summary: `Server is reachable but ${healthPath} failed (HTTP ${health.status ?? "error"}). Likely a wrong path or an old server version.`, + } } if (!internet.ok) { return { classification: "no-internet", summary: "The device has no working internet/network at all (public check also failed). Check Wi-Fi/data and Tailscale (VPN) status." } diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 35036e058..51d58d2dc 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -7,6 +7,7 @@ import * as Device from "expo-device" import appJson from "../../app.json" import { log, formatLogLines } from "./logbuffer" import { type Classification, type ProbeAttempt, type ParsedUrl, parseUrl, classify } from "./diagnostics-classify" +import { isJsonContentType } from "./server-version" import { redactHostAndUrls } from "./scrub" export type { Classification, ProbeAttempt } from "./diagnostics-classify" @@ -23,6 +24,10 @@ export interface DiagnosticReport { host?: string port?: string isHostname: boolean + // Which API family the server answered /api/info with, and the version it + // reported. Undefined when that probe could not reach the server. + apiMajor?: "v1" | "v2" + apiVersion?: string attempts: ProbeAttempt[] device: { platform: string @@ -39,14 +44,39 @@ export interface DiagnosticReport { // made classify() short-circuit to "connection actually works now" even when // auth failed or the server errored. The root probe only checks reachability // (any HTTP response, even an error status, proves something is listening). -async function timedFetch(name: string, target: string, init?: RequestInit, opts?: { requireOk?: boolean }): Promise { +// readBody: also record the response's content type (and the JSON version, if +// any). Needed because both majors answer 200 + HTML for paths they do not +// implement, so a status code alone cannot tell an API from a web app. +async function timedFetch( + name: string, + target: string, + init?: RequestInit, + opts?: { requireOk?: boolean; readBody?: boolean }, +): Promise { const requireOk = opts?.requireOk ?? true const start = Date.now() const controller = new AbortController() const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) try { const res = await fetch(target, { ...init, signal: controller.signal }) - return { name, target, ok: requireOk ? res.ok : true, status: res.status, durationMs: Date.now() - start } + const contentType = res.headers.get("content-type") ?? undefined + let version: string | undefined + if (opts?.readBody) { + const text = await res.text() + const parsed = safeJson(text) + if (parsed && typeof (parsed as { version?: unknown }).version === "string") { + version = (parsed as { version: string }).version + } + } + return { + name, + target, + ok: requireOk ? res.ok : true, + status: res.status, + durationMs: Date.now() - start, + contentType, + version, + } } catch (error: unknown) { const err = error as { name?: string; message?: string; cause?: unknown } const aborted = err?.name === "AbortError" @@ -63,6 +93,14 @@ async function timedFetch(name: string, target: string, init?: RequestInit, opts } } +function safeJson(text: string): unknown { + try { + return JSON.parse(text) as unknown + } catch { + return null + } +} + // Every host probed this session. Log lines mention hosts without a scheme // (so URL-based scrubbing misses them), and a crash report has no host of its // own — this set lets formatReportForSupport redact them all regardless. @@ -79,15 +117,35 @@ export async function probeConnection(url: string, auth?: { username: string; pa let health: ProbeAttempt let root: ProbeAttempt let internet: ProbeAttempt + let info: ProbeAttempt | undefined + let major: "v1" | "v2" | undefined if (parsed.valid) { const base = `${parsed.scheme}://${parsed.host}:${parsed.port}` - ;[health, root, internet] = await Promise.all([ - timedFetch("health", `${base}/global/health`, { headers }), + // /api/info first: it is what the app itself probes to choose an API + // family, and it answers even when the status code is an error, so it tells + // us *which* server we are diagnosing before we interpret anything else. + const apiInfo = await timedFetch("api-info", `${base}/api/info`, { headers }, { requireOk: false, readBody: true }) + apiInfo.authProvided = Boolean(auth?.password) + info = apiInfo + if (!apiInfo.error) { + major = isJsonContentType(apiInfo.contentType) && /^([2-9])\./.test(apiInfo.version ?? "") ? "v2" : "v1" + } + + const answered = apiInfo.status != null && apiInfo.status < 400 + if (major === "v2") { + // 2.x has no /global/health; its version route is its health check (the + // same call client.global.health makes). HTML here means no API. + health = { ...apiInfo, name: "health", ok: answered && isJsonContentType(apiInfo.contentType) } + } else { + health = await timedFetch("health", `${base}/global/health`, { headers }) + health.authProvided = Boolean(auth?.password) + } + + ;[root, internet] = await Promise.all([ timedFetch("server-root", `${base}/`, { headers }, { requireOk: false }), timedFetch("internet", INTERNET_CHECK_URL), ]) - health.authProvided = Boolean(auth?.password) } else { const skipped: ProbeAttempt = { name: "health", target: url, ok: false, durationMs: 0, error: "skipped: malformed url" } health = skipped @@ -95,7 +153,11 @@ export async function probeConnection(url: string, auth?: { username: string; pa internet = await timedFetch("internet", INTERNET_CHECK_URL) } - const { classification, summary } = classify(parsed, health, internet, root) + const { classification, summary } = classify(parsed, health, internet, root, { + major, + healthPath: major === "v2" ? "/api/info" : "/global/health", + }) + log.info("diag", "api probe", major ?? "unknown", info?.version ?? "", info?.contentType ?? "") const report: DiagnosticReport = { classification, @@ -105,7 +167,9 @@ export async function probeConnection(url: string, auth?: { username: string; pa host: parsed.host, port: parsed.port, isHostname: parsed.isHostname, - attempts: [health, root, internet], + apiMajor: major, + apiVersion: info?.version, + attempts: [...(info && info.name !== health.name ? [info] : []), health, root, internet], device: { platform: Platform.OS, osVersion: String(Platform.Version), @@ -129,6 +193,7 @@ export function formatReport(report: DiagnosticReport): string { lines.push(`Target URL: ${report.url}`) lines.push(` scheme=${report.scheme} host=${report.host} port=${report.port} hostname=${report.isHostname}`) lines.push("") + lines.push(`API: ${report.apiMajor ? `detected ${report.apiMajor}` : "unknown (could not probe /api/info)"}${report.apiVersion ? ` version ${report.apiVersion}` : ""}`) lines.push("Probes:") for (const a of report.attempts) { const status = a.ok ? `OK ${a.status ?? ""}`.trim() : `FAIL ${a.error ?? ""}`.trim() diff --git a/src/lib/sdk-v2.test.ts b/src/lib/sdk-v2.test.ts new file mode 100644 index 000000000..5e2f2a2bb --- /dev/null +++ b/src/lib/sdk-v2.test.ts @@ -0,0 +1,574 @@ +import { test, before, after } from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { createMockOpencodeServer } from "../../tests/fixtures/mock-opencode-server.ts" +import { createV2Client, mapMessage, V2EventTranslator, clearRememberedSessions } from "./sdk-v2.ts" +import { detectServerMajor, clearServerMajorCache, cachedServerMajor, majorFromResponse } from "./server-version.ts" +import type { Event, MessageWithParts } from "./sdk.ts" + +const capture = (name: string) => readFileSync(fileURLToPath(new URL(`../../docs/opencode-v2-port/${name}`, import.meta.url)), "utf8") + +const sample = (name: string) => JSON.parse(capture(name)) + +/** SSE capture -> the event objects it carries. */ +const sseEvents = (name: string): Array> => + capture(name) + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice(6))) + +const SAMPLE_MESSAGES = sample("sample-messages.json").data +const SAMPLE_EVENTS = sseEvents("sample-events.sse") + +const AUTH = { username: "opencode", password: "forge-test-pass" } + +function v2Translator(): V2EventTranslator { + return new V2EventTranslator() +} + +/** Translate the whole captured 2.0.29 chat stream. */ +function replay(events: Array>): Event[] { + const translator = v2Translator() + const out: Event[] = [] + for (const event of events) out.push(...translator.translate(event as never)) + return out +} + +function latestPart(events: Event[], messageID: string): Record | undefined { + for (const event of [...events].reverse()) { + const part = (event.properties as { part?: Record }).part + if (part?.messageID === messageID) return part + } + return undefined +} + +// --- message mapping --------------------------------------------------------- + +test("mapMessage: a captured user entry becomes a user message with one text part", () => { + const user = SAMPLE_MESSAGES.find((m: Record) => m.type === "user") + const mapped = mapMessage(user as never, "ses_sample") + assert.equal(mapped.info.id, user.id) + assert.equal(mapped.info.sessionID, "ses_sample") + assert.equal(mapped.info.role, "user") + assert.equal(mapped.parts.length, 1) + assert.equal(mapped.parts[0].id, `${user.id}:text:0`) + assert.match(mapped.parts[0].text ?? "", /number of entries/) +}) + +test("mapMessage: a captured assistant entry becomes reasoning/tool/text parts", () => { + const assistant = SAMPLE_MESSAGES.find( + (m: Record) => m.type === "assistant" && JSON.stringify(m.content).includes('"tool"'), + ) + const mapped = mapMessage(assistant as never, "ses_sample") + assert.equal(mapped.info.role, "assistant") + assert.equal(mapped.info.agent, "build") + assert.equal(mapped.info.modelID, "work") + assert.equal(mapped.info.providerID, "dgx") + + const kinds = mapped.parts.map((p) => p.type) + assert.deepEqual(kinds, ["reasoning", "tool"]) + + const tool = mapped.parts.find((p) => p.type === "tool") + assert.equal(tool?.id, `${assistant.id}:tool:call_792d2014cdc9167a91e4dc28`) + assert.equal(tool?.callID, "call_792d2014cdc9167a91e4dc28") + // 2.x renamed `shell`; the UI's tool renderers still key off `bash`. + assert.equal(tool?.tool, "bash") + assert.equal(tool?.state?.status, "completed") + assert.deepEqual(tool?.state?.input, { command: "ls" }) + assert.match(String(tool?.state?.output), /CLAUDE\.md/) +}) + +test("mapMessage: part ids match the ids the streaming translator synthesises", () => { + // The transcript refetch after a live run must not duplicate parts, so both + // sides have to derive the same id. + const translator = v2Translator() + const messageID = "msg_stream" + const events = replayEvents(translator, [ + ["session.step.started", { sessionID: "s", assistantMessageID: messageID, agent: "build", model: { id: "work", providerID: "dgx" } }], + ["session.reasoning.started", { sessionID: "s", assistantMessageID: messageID, ordinal: 0 }], + ["session.reasoning.delta", { sessionID: "s", assistantMessageID: messageID, ordinal: 0, delta: "Hi" }], + ["session.text.started", { sessionID: "s", assistantMessageID: messageID, ordinal: 0 }], + ["session.text.delta", { sessionID: "s", assistantMessageID: messageID, ordinal: 0, delta: " there" }], + ]) + const parts = [...new Set( + events + .filter((e) => e.type === "message.part.updated") + .map((e) => (e.properties as { part: { id: string } }).part.id), + )] + assert.deepEqual(parts, [`${messageID}:reasoning:0`, `${messageID}:text:0`]) + + const fetched = mapMessage( + { + id: messageID, + type: "assistant", + time: { created: 1 }, + content: [ + { type: "reasoning", text: "Hi" }, + { type: "text", text: " there" }, + ], + } as never, + "s", + ) + assert.deepEqual(fetched.parts.map((p) => p.id), parts) +}) + +test("mapMessage: the idle marker and unknown entry types are dropped", () => { + assert.equal(mapMessage({ id: "x", type: "idle", outcome: "succeeded" } as never, "s"), null) + assert.equal(mapMessage({ id: "x", type: "compaction" } as never, "s"), null) +}) + +function replayEvents(translator: V2EventTranslator, frames: Array<[string, Record]>): Event[] { + const out = [] as Event[] + for (const [type, data] of frames) out.push(...translator.translate({ id: `e-${type}`, created: 1, type, data } as never)) + return out +} + +// --- event mapping ----------------------------------------------------------- + +test("V2EventTranslator: the captured chat stream ends with the text part the transcript shows", () => { + const events = replay(SAMPLE_EVENTS) + const created = events.find((e) => e.type === "session.created") + assert.equal( + (created?.properties as { info: { id: string } }).info.id, + "ses_e3472121affe4vLPUtwHxSd4xs", + ) + + const statuses = events.filter((e) => e.type === "session.status") + assert.deepEqual( + statuses.map((e) => (e.properties as { status: { type: string } }).status.type), + ["busy", "idle"], + ) + + // The last streamed text delta ("14") must have accumulated into a complete + // part, exactly like the GET /message response for the same chat. + const finalMessage = "msg_1cb8e4e8c001r25R4wH8JiB79T" + const part = latestPart(events, finalMessage) + assert.equal(part?.type, "text") + assert.equal(part?.id, `${finalMessage}:text:0`) + assert.equal(part?.text, "14") +}) + +test("V2EventTranslator: deltas accumulate; the ended frame's authoritative text wins", () => { + const events = replayEvents(new V2EventTranslator(), [ + ["session.text.started", { sessionID: "s", assistantMessageID: "m", ordinal: 0 }], + ["session.text.delta", { sessionID: "s", assistantMessageID: "m", ordinal: 0, delta: "He" }], + ["session.text.delta", { sessionID: "s", assistantMessageID: "m", ordinal: 0, delta: "llo" }], + ["session.text.ended", { sessionID: "s", assistantMessageID: "m", ordinal: 0, text: "Hello!" }], + ]) + const last = latestPart(events, "m") + assert.equal(last?.text, "Hello!") +}) + +test("V2EventTranslator: reasoning deltas stream into a reasoning part", () => { + const events = replayEvents(new V2EventTranslator(), [ + ["session.reasoning.started", { sessionID: "s", assistantMessageID: "m", ordinal: 0 }], + ["session.reasoning.delta", { sessionID: "s", assistantMessageID: "m", ordinal: 0, delta: "thin" }], + ["session.reasoning.delta", { sessionID: "s", assistantMessageID: "m", ordinal: 0, delta: "king" }], + ]) + const part = latestPart(events, "m") + assert.equal(part?.type, "reasoning") + assert.equal(part?.text, "thinking") +}) + +test("V2EventTranslator: tool events become a running part, then a completed one", () => { + const events = replayEvents(new V2EventTranslator(), [ + ["session.step.started", { sessionID: "s", assistantMessageID: "m", agent: "build", model: { id: "work", providerID: "dgx" } }], + ["session.tool.input.started", { sessionID: "s", assistantMessageID: "m", id: "c1", name: "shell" }], + ["session.tool.input.ended", { sessionID: "s", assistantMessageID: "m", id: "c1", text: '{"command":"ls"}' }], + ["session.tool.success", { sessionID: "s", assistantMessageID: "m", id: "c1", content: [{ type: "text", text: "out" }] }], + ]) + const part = latestPart(events, "m") + assert.equal(part?.type, "tool") + assert.equal(part?.tool, "bash") + assert.equal(part?.state?.status, "completed") + assert.equal((part?.state?.output as string), "out") + + const running = events.find((e) => { + const p = (e.properties as { part?: { state?: { status?: string } } }).part + return p?.state?.status === "running" + }) + assert.ok(running) +}) + +test("V2EventTranslator: a user inbox item replaces the optimistic message", () => { + const events = replayEvents(new V2EventTranslator(), [ + [ + "session.inbox.enqueued", + { sessionID: "s", inboxID: "msg_u1", item: { type: "user", payload: { text: "run ls" }, delivery: "steer" } }, + ], + ["session.inbox.delivered", { sessionID: "s", inboxID: "msg_u1" }], + ]) + const updated = events.find((e) => e.type === "message.updated") + assert.equal((updated?.properties as { info: { id: string; role: string } }).info.id, "msg_u1") + assert.equal((updated?.properties as { info: { role: string } }).info.role, "user") + const part = (events.find((e) => e.type === "message.part.updated")?.properties as { part: { text: string } }).part + assert.equal(part.text, "run ls") +}) + +test("V2EventTranslator: execution failure surfaces session.error plus idle", () => { + const events = replayEvents(new V2EventTranslator(), [ + ["session.execution.failed", { sessionID: "s", error: { message: "boom" } }], + ]) + assert.deepEqual(events.map((e) => e.type), ["session.error", "session.status"]) + assert.equal((events[0].properties as { error: { message: string } }).error.message, "boom") + assert.equal((events[1].properties as { status: { type: string } }).status.type, "idle") +}) + +test("V2EventTranslator: permission.asked maps onto the 1.x prompt shape", () => { + const events = replay(sseEvents("sample-permission-events.sse")) + const event = events.find((e) => e.type === "permission.asked") + const props = event?.properties as Record + assert.equal(props.permission, "shell") + assert.deepEqual(props.patterns, ["echo hello"]) + assert.ok((props.tool as { messageID: string }).messageID) +}) + +test("V2EventTranslator: form.created maps onto question.asked with answer keys", () => { + const events = replay(sseEvents("sample-form-events.sse")) + const event = events.find((e) => e.type === "question.asked") + const props = event?.properties as { + id: string + sessionID: string + questions: Array<{ header: string; question: string; options: Array<{ label: string }> }> + keys: string[] + } + assert.ok(props.id.startsWith("frm_")) + assert.equal(props.keys[0], "q0") + assert.equal(props.questions[0].header, "Logo color") + assert.deepEqual( + props.questions[0].options.map((o) => o.label), + ["Red", "Blue"], + ) +}) + +// --- version detection ------------------------------------------------------- + +test("detectServerMajor: 2.x JSON is v2", async () => { + clearServerMajorCache() + const mock = createMockOpencodeServer({ port: 4211, apiVersion: "v2" }) + await mock.listen() + try { + assert.equal(await detectServerMajor({ baseUrl: mock.url, auth: AUTH }, { force: true }), "v2") + } finally { + await mock.close() + } +}) + +test("detectServerMajor: a 1.x server answering with HTML stays v1", async () => { + clearServerMajorCache() + const { createServer } = await import("node:http") + const html = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }) + res.end("opencode") + }) + await new Promise((resolve) => html.listen(4212, "127.0.0.1", resolve)) + try { + assert.equal(await detectServerMajor({ baseUrl: "http://127.0.0.1:4212", auth: AUTH }, { force: true }), "v1") + } finally { + await new Promise((resolve) => html.close(resolve)) + } +}) + +test("detectServerMajor: a 1.x server that 404s the probe is v1", async () => { + clearServerMajorCache() + const mock = createMockOpencodeServer({ port: 4213 }) + await mock.listen() + try { + assert.equal(await detectServerMajor({ baseUrl: mock.url, auth: AUTH }, { force: true }), "v1") + } finally { + await mock.close() + } +}) + +test("detectServerMajor: a waking forge workspace gets no verdict", async () => { + clearServerMajorCache() + const { createServer } = await import("node:http") + const waking = createServer((_req, res) => { + res.writeHead(503, { "X-Forge-State": "waking", "Retry-After": "5", "Content-Type": "application/json" }) + res.end(JSON.stringify({ forge: { state: "waking" } })) + }) + await new Promise((resolve) => waking.listen(4214, "127.0.0.1", resolve)) + try { + // Must not settle on v1: the same URL answers 2.x once it is awake. + assert.equal(cachedServerMajor({ baseUrl: "http://127.0.0.1:4214", auth: AUTH }), undefined) + await detectServerMajor({ baseUrl: "http://127.0.0.1:4214", auth: AUTH }, { force: true }) + assert.equal(cachedServerMajor({ baseUrl: "http://127.0.0.1:4214", auth: AUTH }), undefined) + } finally { + await new Promise((resolve) => waking.close(resolve)) + } +}) + +test("client: delete and abort reach the 2.x routes", async () => { + clearServerMajorCache() + await withV2Mock(async ({ client, seen }) => { + const session = await client.session.create({ title: "lifecycle" }) + assert.equal(await client.session.abort(session.id), true) + await waitFor(() => seen.find((e) => e.type === "session.status" && (e.properties as { status: { type: string } }).status.type === "idle")) + await client.session.delete(session.id) + const listed = await client.session.list() + assert.equal( + listed.some((s) => s.id === session.id), + false, + ) + }) +}) + +test("majorFromResponse: only 2.x JSON counts as v2 — status is irrelevant", () => { + assert.equal(majorFromResponse("application/json", { version: "2.0.29" }), "v2") + assert.equal(majorFromResponse("application/json; charset=utf-8", { version: "2.2.7" }), "v2") + // A 1.x server answers this exact URL with 200 + its web app. + assert.equal(majorFromResponse("text/html", ""), "v1") + assert.equal(majorFromResponse(null, null), "v1") + // A 2.x-shaped version in a JSON body we cannot trust as a 2.x server only if + // it is 1.x JSON — 1.x never reports a 2.x version. + assert.equal(majorFromResponse("application/json", { version: "1.18.32" }), "v1") + assert.equal(majorFromResponse("application/json", null), "v1") +}) + +test("V2EventTranslator: revert events keep the pending-revert marker in sync", () => { + const staged = replay([{ type: "session.revert.staged", data: { sessionID: "ses_1", revert: { messageID: "msg_user1", files: [] } } }]) + assert.equal(staged.length, 1) + assert.deepEqual((staged[0].properties as { revert: { messageID: string } }).revert, { messageID: "msg_user1", partID: undefined }) + + // 2.x commits the staged revert itself when the next prompt arrives, and the + // transcript filter is driven purely by this marker. A commit also drops the + // edited message and everything after it, named only by the first id. + const committed = replay([{ type: "session.revert.committed", data: { sessionID: "ses_1", to: "msg_user1" } }]) + assert.equal(committed.length, 1) + assert.equal((committed[0].properties as { revert?: unknown }).revert, undefined) + assert.equal((committed[0].properties as { droppedFrom?: string }).droppedFrom, "msg_user1") + + const cleared = replay([{ type: "session.revert.cleared", data: { sessionID: "ses_1" } }]) + assert.equal(cleared.length, 1) + assert.equal((cleared[0].properties as { revert?: unknown }).revert, undefined) + assert.equal(cleared[0].properties.droppedFrom, undefined) +}) + +test("client: pages a long transcript without combining cursor and order", async () => { + clearServerMajorCache() + await withV2Mock(async ({ client, mock }) => { + const session = await client.session.create({ title: "paging" }) + // 4 pairs = 8 entries, and the mock hands out 4 per page, so this read must + // follow at least one cursor. + mock.v2?.seedHistory(session.id, 4) + const list = await client.session.messages(session.id) + assert.equal(list.length, 8, "every page must be followed") + assert.equal(new Set(list.map((m) => m.info.id)).size, 8, "no duplicate messages across pages") + // Oldest-first for the stores, newest-first on the wire. + assert.deepEqual( + list.map((m) => m.info.role), + ["user", "assistant", "user", "assistant", "user", "assistant", "user", "assistant"], + ) + const texts = list.map((m) => (m.parts.find((p) => p.type === "text") as { text?: string } | undefined)?.text ?? "") + assert.match(texts[0], /older question 0/) + assert.match(texts[6], /older question 3/) + }) +}) + +// --- client round trips against the 2.x mock --------------------------------- + +async function* openEvents(url: string, signal: AbortSignal): AsyncGenerator { + const translator = new V2EventTranslator() + const response = await fetch(`${url}/api/event`, { signal, headers: { Authorization: `Basic ${btoa("opencode:forge-test-pass")}` } }) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffer = "" + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split("\n\n") + buffer = frames.pop() ?? "" + for (const frame of frames) { + for (const line of frame.split("\n")) { + if (!line.startsWith("data: ")) continue + yield* translator.translate(JSON.parse(line.slice(6))) + } + } + } +} + +/** Polls a (possibly async) predicate until it yields a defined value. */ +async function waitFor(what: () => T | undefined | Promise, ms = 3000): Promise { + const started = Date.now() + for (;;) { + const value = await what() + if (value) return value + if (Date.now() - started > ms) throw new Error("timed out waiting for condition") + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +let nextPort = 4230 + +/** + * Boots the 2.x mock, builds a client whose event stream is translated through + * V2EventTranslator, and records every translated event. The stream is aborted + * and the server closed afterwards, so no test leaves a connection open. + */ +async function withV2Mock(run: (ctx: { url: string; mock: ReturnType; client: ReturnType; seen: Event[] }) => Promise): Promise { + const port = nextPort++ + const mock = createMockOpencodeServer({ port, apiVersion: "v2" }) + await mock.listen() + const controller = new AbortController() + const seen: Event[] = [] + const client = createV2Client({ baseUrl: mock.url, directory: "/tmp", auth: AUTH }, (signal) => openEvents(mock.url, signal ?? controller.signal)) + const pump = (async () => { + for await (const event of client.global.events(controller.signal)) seen.push(event) + })() + pump.catch(() => {}) + // The mock only broadcasts to sockets that are already attached, so tests + // that script an ask must not race the stream opening. + await waitFor(() => (mock.v2?.connected() ? mock.v2.connected() : undefined)) + try { + return await run({ url: mock.url, mock, client, seen }) + } finally { + controller.abort() + await Promise.race([pump.catch(() => {}), new Promise((resolve) => setTimeout(resolve, 200))]) + await mock.close() + } +} + +test("client: creates a session, streams the reply, and ends idle", async () => { + clearServerMajorCache() + await withV2Mock(async ({ client, seen }) => { + const session = await client.session.create({ title: "streaming" }) + assert.ok(session.id.startsWith("ses_")) + assert.equal(session.directory, "/tmp") + + await client.session.prompt(session.id, { parts: [{ type: "text", text: "how many files?" }] }) + + const messages = await waitFor(async () => { + const list = await client.session.messages(session.id, { limit: 10 }) + return list.some((m) => m.info.role === "assistant") ? list : undefined + }) + + // newest-first paging comes back oldest-first to the stores + assert.deepEqual( + messages.map((m) => m.info.role), + ["user", "assistant"], + ) + const parts = messages.find((m) => m.info.role === "assistant")!.parts + assert.deepEqual( + parts.map((p) => p.type), + ["reasoning", "tool", "text"], + ) + assert.equal(parts.find((p) => p.type === "tool")?.tool, "bash") + + // The granular stream must land on the events the stores dispatch on. + await waitFor(() => seen.find((e) => e.type === "session.status" && (e.properties as { status: { type: string } }).status.type === "idle")) + assert.ok(seen.some((e) => e.type === "session.created")) + assert.ok(seen.some((e) => e.type === "message.updated")) + assert.ok(seen.some((e) => e.type === "message.part.updated")) + }) +}) + +test("client: refresh picks up parts delivered while the stream was down", async () => { + clearServerMajorCache() + await withV2Mock(async ({ client }) => { + const session = await client.session.create({ title: "reconnect" }) + await client.session.prompt(session.id, { parts: [{ type: "text", text: "count them" }] }) + const list = await waitFor(async () => { + const fetched = await client.session.messages(session.id) + return fetched.some((m) => m.info.role === "assistant") ? fetched : undefined + }) + const assistant = list.find((m) => m.info.role === "assistant")! + assert.ok(assistant.parts.some((p) => p.type === "text" && (p.text ?? "").length > 0)) + }) +}) + +test("client: revert stages and unstages the session", async () => { + clearServerMajorCache() + await withV2Mock(async ({ client }) => { + const session = await client.session.create({ title: "revert" }) + const staged = await client.session.revert(session.id, "msg_1") + assert.equal(staged.revert?.messageID, "msg_1") + const restored = await client.session.unrevert(session.id) + assert.equal(restored.revert, undefined) + }) +}) + +test("client: provider and agent catalogues map onto the 1.x shapes", async () => { + clearServerMajorCache() + await withV2Mock(async ({ client }) => { + const providers = await client.provider.list() + assert.equal(providers.all[0]?.id, "mock") + assert.equal(providers.connected.includes("mock"), true) + const model = providers.all[0]?.models["mock-model"] + assert.equal(model?.reasoning, true) + assert.deepEqual(Object.keys(model?.variants ?? {}), ["low", "high"]) + assert.equal(providers.default.mock, "mock-model") + + const agents = await client.agent.list() + assert.equal(agents[0]?.name, "build") + const commands = await client.command.list() + assert.equal(commands[0]?.name, "review") + }) +}) + +test("client: vcs and file listings map onto the 1.x shapes", async () => { + clearServerMajorCache() + await withV2Mock(async ({ client }) => { + const vcs = await client.vcs.get() + assert.equal(vcs.branch, "mock-branch") + assert.equal(vcs.default_branch, "main") + const diff = await client.vcs.diff("git") + assert.equal(diff[0]?.file, "README.md") + + const files = await client.file.list({ path: "." }) + assert.deepEqual(files.map((f) => f.name), ["src", "README.md"]) + assert.equal(files[0]?.type, "directory") + assert.equal(await client.file.roots(), null) + }) +}) + +test("permission round trip: asked -> list -> reply -> replied", async () => { + clearServerMajorCache() + clearRememberedSessions() + await withV2Mock(async ({ mock, client, seen }) => { + const askedID = mock.v2?.askPermission(V2_SEED_SESSION) + assert.ok(askedID) + + // The event is what teaches the client which session owns the request — + // 2.x replies are session-scoped, unlike 1.x. + const askedEvent = await waitFor(() => seen.find((e) => e.type === "permission.asked")) + assert.equal((askedEvent.properties as { id: string }).id, askedID) + + const pending = await client.permission.list() + assert.equal(pending[0]?.id, askedID) + assert.equal(pending[0]?.permission, "shell") + assert.deepEqual(pending[0]?.patterns, ["rm -rf /tmp/mock"]) + + assert.equal(await client.permission.reply(askedID as string, "once"), true) + await waitFor(() => seen.find((e) => e.type === "permission.replied")) + assert.deepEqual(mock.v2?.pendingPermissions(), []) + }) +}) + +test("question round trip: form created -> list -> answer -> replied", async () => { + clearServerMajorCache() + clearRememberedSessions() + await withV2Mock(async ({ mock, client, seen }) => { + const formID = mock.v2?.askForm(V2_SEED_SESSION) + assert.ok(formID) + + const asked = await waitFor(() => seen.find((e) => e.type === "question.asked")) + const props = asked.properties as { id: string; questions: Array<{ options: Array<{ label: string }> }> } + assert.equal(props.id, formID) + assert.deepEqual( + props.questions[0].options.map((o) => o.label), + ["Red", "Blue"], + ) + + const pending = await client.question.list() + assert.equal(pending[0]?.id, formID) + assert.equal(pending[0]?.questions[0]?.header, "Logo color") + + assert.equal(await client.question.reply(formID as string, [["Red"]]), true) + await waitFor(() => seen.find((e) => e.type === "question.replied")) + assert.deepEqual(mock.v2?.pendingForms(), []) + }) +}) + +const V2_SEED_SESSION = "ses_mockv2000000000000000000" diff --git a/src/lib/sdk-v2.ts b/src/lib/sdk-v2.ts new file mode 100644 index 000000000..b0afe25bd --- /dev/null +++ b/src/lib/sdk-v2.ts @@ -0,0 +1,1096 @@ +// opencode 2.x adapter. +// +// Keeps the `Client` surface `createClient` exposes (src/lib/sdk.ts) while +// talking to the 2.x API: everything under `/api/*`, `{"data": …}` response +// envelopes, `?location[directory]=` scoping, cursor-paginated +// newest-first message lists, and the granular `session.*` event stream. +// +// The store-facing vocabulary stays 1.x: `Session` / `Message` / `Part` and the +// `message.updated` / `message.part.updated` / `session.status` events the +// sessions and events stores already dispatch on, so no screen or store had to +// change. +import { buildRequestHeaders } from "./headers.ts" +import { ApiError, apiErrorFor } from "./api-error.ts" +import { parseForgeState } from "./forge.ts" +import { forgePrPath, readForgePr, type ForgePr } from "./forge-pr.ts" +import type { + Agent, + Client, + Command, + Event, + FileDiff, + FileEntry, + HealthResponse, + Message, + MessageWithParts, + Part, + PermissionRequest, + Project, + QuestionRequest, + Session, + VcsInfo, + VcsMode, +} from "./sdk.ts" + +export interface V2ClientConfig { + baseUrl: string + directory?: string + auth?: { username: string; password: string } +} + +const MAX_MESSAGE_PAGES = 20 +const MAX_SESSION_PAGES = 5 + +// v2 renamed several tools. The UI switches on the 1.x names (ToolCallCard's +// `case "bash"` etc.), so the adapter renames back instead of touching screens. +const TOOL_NAME_TO_V1: Record = { + shell: "bash", + subagent: "task", + edit: "edit", + write: "write", + patch: "apply_patch", + read: "read", + glob: "glob", + grep: "grep", + list: "list", + codesearch: "codesearch", + webfetch: "webfetch", + websearch: "websearch", +} + +export function v2ToolName(name: string | undefined): string { + if (!name) return "" + return TOOL_NAME_TO_V1[name] ?? name +} + +// Session ids for pending permission requests and forms. Both reply routes are +// session-scoped in 2.x (`/api/session/{sessionID}/permission/{requestID}/reply` +// and `/api/session/{sessionID}/form/{formID}/reply`) while the 1.x-shaped +// `permission.reply(requestID, …)` / `question.reply(requestID, …)` the screens +// call carry no session id, so the ids are remembered as they are seen (in +// events and in the pending lists) and looked up at reply time. +const permissionSessions = new Map() +const formSessions = new Map() + +export function rememberPermissionSession(requestID: string, sessionID: string): void { + permissionSessions.set(requestID, sessionID) +} + +export function rememberFormSession(formID: string, sessionID: string): void { + formSessions.set(formID, sessionID) +} + +export function clearRememberedSessions(): void { + permissionSessions.clear() + formSessions.clear() +} + +// --- mapping ----------------------------------------------------------------- + +interface V2Location { + directory?: string +} + +interface V2Session { + id: string + projectID?: string + parentID?: string + title?: string + slug?: string + subpath?: string + version?: string + location?: V2Location + time?: { created?: number; updated?: number; archived?: number } + revert?: { messageID: string; partID?: string } + outcome?: string +} + +export function mapSession(session: V2Session): Session { + return { + id: session.id, + slug: session.slug ?? session.id.slice(-8), + projectID: session.projectID ?? "", + directory: session.location?.directory ?? "", + parentID: session.parentID, + title: session.title ?? "", + version: session.version ?? "", + time: { + created: session.time?.created ?? 0, + updated: session.time?.updated ?? 0, + archived: session.time?.archived, + }, + revert: session.revert ? { messageID: session.revert.messageID, partID: session.revert.partID } : undefined, + } +} + +interface V2ToolContent { + type?: string + text?: string +} + +function joinContent(content: V2ToolContent[] | undefined): string { + if (!Array.isArray(content)) return "" + return content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("") +} + +function mapToolState(state: Record | undefined): Part["state"] { + if (!state) return { status: "running" } + const status = state.status as string + if (status === "completed") { + return { + status: "completed", + input: state.input, + output: joinContent(state.content as V2ToolContent[]), + title: undefined, + time: state.time as { start?: number; end?: number } | undefined, + } + } + if (status === "error") { + const error = state.error as { message?: string } | undefined + return { + status: "error", + input: state.input, + output: joinContent(state.content as V2ToolContent[]), + error: { message: error?.message ?? "Tool failed" }, + } + } + return { status: "running", input: state.input } +} + +interface V2Message { + id: string + type: string + time?: { created?: number; streamed?: number; completed?: number } + text?: string + files?: Array<{ mime?: string; data?: string; name?: string; source?: { type?: string; uri?: string } }> + agents?: Array<{ name?: string }> + skills?: Array<{ name?: string }> + // assistant + agent?: string + model?: { id?: string; providerID?: string } + content?: Array> + finish?: string + cost?: number + tokens?: Message["tokens"] + outcome?: string +} + +/** + * Map one v2 message-list entry to the 1.x `{info, parts}` pair the sessions + * store parses. Non-chat entries (the `idle` marker, compaction/system rows) + * return null so callers drop them. + * + * Part ids must be identical to the ones the streaming event translator + * synthesises, otherwise a live-updated part would be duplicated by the next + * refetch. Both sides therefore derive ids from the same scheme: + * `::`, tools keyed by the provider tool-call id. + */ +export function mapMessage(entry: V2Message, sessionID = ""): MessageWithParts | null { + if (entry.type === "user") { + const parts: Part[] = [] + if (typeof entry.text === "string") { + parts.push({ id: `${entry.id}:text:0`, sessionID: sessionID || undefined, messageID: entry.id, type: "text", text: entry.text }) + } + for (const [i, file] of (entry.files ?? []).entries()) { + const url = file.source?.uri ?? (file.data ? `data:${file.mime ?? "application/octet-stream"};base64,${file.data}` : "") + parts.push({ + id: `${entry.id}:file:${i}`, + sessionID: sessionID || undefined, + messageID: entry.id, + type: "file", + mime: file.mime, + url, + filename: file.name, + }) + } + return { + info: { + id: entry.id, + sessionID, + role: "user", + time: { created: entry.time?.created ?? 0, completed: entry.time?.completed }, + agent: entry.agents?.[0]?.name, + }, + parts, + } + } + + if (entry.type === "assistant") { + const parts: Part[] = [] + const ordinal: Record = { reasoning: 0, text: 0 } + for (const item of entry.content ?? []) { + const kind = item.type as string + if (kind === "reasoning" || kind === "text") { + const index = ordinal[kind] ?? 0 + ordinal[kind] = index + 1 + parts.push({ + id: `${entry.id}:${kind}:${index}`, + sessionID: sessionID || undefined, + messageID: entry.id, + type: kind as Part["type"], + text: (item.text as string) ?? "", + time: item.time as Part["time"], + }) + } else if (kind === "tool") { + const callID = item.id as string + parts.push({ + id: `${entry.id}:tool:${callID}`, + sessionID: sessionID || undefined, + messageID: entry.id, + type: "tool", + tool: v2ToolName(item.name as string | undefined), + callID, + state: mapToolState(item.state as Record), + time: { start: (item.time as { created?: number } | undefined)?.created, end: (item.time as { completed?: number } | undefined)?.completed }, + }) + } + } + return { + info: { + id: entry.id, + sessionID, + role: "assistant", + time: { created: entry.time?.created ?? 0, completed: entry.time?.completed }, + agent: entry.agent, + modelID: entry.model?.id, + providerID: entry.model?.providerID, + cost: entry.cost, + tokens: entry.tokens, + finish: entry.finish, + }, + parts, + } + } + + return null +} + +// --- event translation ------------------------------------------------------- + +type V2Event = { id?: string; created?: number; type: string; data?: Record; location?: V2Location } + +/** + * Accumulating translator: v2 streams granular deltas (`session.text.delta` + * without the text seen so far), while 1.x `message.part.updated` events carry + * a complete part the sessions store replaces wholesale. This class buffers per + * part and emits only complete parts, so the two sides of the app cannot see + * half a message. + */ +export class V2EventTranslator { + private messages = new Map() + private texts = new Map() + // Provider tool ids carry only `name` on the first frame; later progress / + // success / error frames do not, so remember it to keep the part's tool set. + private toolNames = new Map() + private toolMessages = new Map() + + translate(event: V2Event): Event[] { + const data = event.data ?? {} + const type = event.type + if (type.startsWith("session.")) return this.session(event, data) + if (type.startsWith("permission.")) return this.permission(event, data) + if (type.startsWith("form.")) return this.form(event, data) + return [] + } + + private info(id: string, sessionID: string, patch: Partial): Message { + const current = this.messages.get(id) ?? { id, sessionID, role: "assistant", time: { created: 0 } } + const next: Message = { ...current, ...patch, sessionID: sessionID || current.sessionID } + this.messages.set(id, next) + return { ...next } + } + + private session(event: V2Event, data: Record): Event[] { + const type = event.type + const sessionID = data.sessionID as string | undefined + switch (type) { + case "session.created": { + // The 2.x event names the id `sessionID` (and carries no time), unlike + // the session GET — normalise so mapSession produces a real Session. + const created = data as Record + return [ + { + type: "session.created", + properties: { + info: mapSession({ + ...created, + id: (created.sessionID as string) ?? (created.id as string), + time: { created: event.created, updated: event.created }, + } as unknown as V2Session), + }, + }, + ] + } + + case "session.inbox.enqueued": { + const item = data.item as { type?: string; payload?: { text?: string } } | undefined + const inboxID = data.inboxID as string | undefined + if (!sessionID || !inboxID || item?.type !== "user") return [] + const info = this.info(inboxID, sessionID, { + role: "user", + time: { created: event.created ?? 0 }, + }) + return [ + { type: "message.updated", properties: { info } }, + { + type: "message.part.updated", + properties: { part: { id: `${inboxID}:text:0`, sessionID, messageID: inboxID, type: "text", text: item.payload?.text ?? "" } }, + }, + ] + } + + case "session.execution.started": + return [{ type: "session.status", properties: { sessionID, status: { type: "busy" } } }] + + case "session.execution.succeeded": + return [{ type: "session.status", properties: { sessionID, status: { type: "idle" } } }] + + case "session.execution.failed": { + const error = data.error as { message?: string } | undefined + return [ + { type: "session.error", properties: { sessionID, error: { message: error?.message ?? "Session failed" } } }, + { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + ] + } + + case "session.execution.interrupted": + return [{ type: "session.status", properties: { sessionID, status: { type: "idle" } } }] + + // 2.x keeps a pending revert across the "edit message" pause and commits + // it itself when the next prompt arrives, so the transcript is rewritten + // without any API call the app made. The transcript hides everything after + // the pending revert point, so these must reach the store: staged sets the + // marker, committed/cleared drop it — otherwise the banner stays up and + // the re-sent message plus its reply stay hidden. + case "session.revert.staged": { + const staged = data.revert as { messageID?: string; partID?: string } | undefined + if (!sessionID || !staged?.messageID) return [] + return [{ type: "session.revert", properties: { sessionID, revert: { messageID: staged.messageID, partID: staged.partID } } }] + } + + case "session.revert.committed": { + const to = data.to as string | undefined + if (!sessionID) return [] + return [ + { + type: "session.revert", + properties: { sessionID, revert: undefined, ...(to ? { droppedFrom: to } : {}) }, + }, + ] + } + + case "session.revert.cleared": + return sessionID ? [{ type: "session.revert", properties: { sessionID, revert: undefined } }] : [] + + case "session.step.started": + return [ + { + type: "message.updated", + properties: { + info: this.info(data.assistantMessageID as string, sessionID ?? "", { + role: "assistant", + agent: data.agent as string | undefined, + modelID: (data.model as { id?: string } | undefined)?.id, + providerID: (data.model as { providerID?: string } | undefined)?.providerID, + time: { created: (data.started as number | undefined) ?? event.created ?? 0 }, + }), + }, + }, + ] + + case "session.step.streamed": + return [ + { + type: "message.updated", + properties: { + info: this.info(data.assistantMessageID as string, sessionID ?? "", { + role: "assistant", + time: { created: 0, completed: undefined }, + }), + }, + }, + ] + + case "session.step.ended": + return [ + { + type: "message.updated", + properties: { + info: this.info(data.assistantMessageID as string, sessionID ?? "", { + role: "assistant", + finish: data.finish as string | undefined, + cost: data.cost as number | undefined, + tokens: data.tokens as Message["tokens"], + time: { created: 0, completed: event.created ?? 0 }, + }), + }, + }, + ] + + case "session.text.started": + case "session.reasoning.started": { + const kind = type === "session.text.started" ? "text" : "reasoning" + return this.part(data, kind, event.type, "") + } + case "session.text.delta": + case "session.reasoning.delta": { + const kind = type === "session.text.delta" ? "text" : "reasoning" + const id = this.partID(data, kind) + if (!id) return [] + this.texts.set(id, (this.texts.get(id) ?? "") + String(data.delta ?? "")) + return this.part(data, kind, event.type, this.texts.get(id) ?? "") + } + case "session.text.ended": + case "session.reasoning.ended": { + const kind = type === "session.text.ended" ? "text" : "reasoning" + const id = this.partID(data, kind) + if (id) this.texts.set(id, String(data.text ?? "")) + return this.part(data, kind, event.type, String(data.text ?? "")) + } + + case "session.tool.input.started": + return this.toolPart(data, event.type, { status: "running" }) + case "session.tool.input.ended": + return this.toolPart(data, event.type, { status: "running", input: parseJson(String(data.text ?? "")) }) + case "session.tool.called": + return this.toolPart(data, event.type, { status: "running", input: data.input }) + case "session.tool.progress": + return this.toolPart(data, event.type, { status: "running" }) + case "session.tool.success": + return this.toolPart(data, event.type, { status: "completed", output: joinContent(data.content as V2ToolContent[]) }) + case "session.tool.error": { + const error = data.error as { message?: string } | undefined + return this.toolPart(data, event.type, { status: "error", error: { message: error?.message ?? "Tool failed" } }) + } + + case "session.usage.updated": + case "session.instructions.updated": + case "session.inbox.delivered": + return [] + + default: + return [] + } + } + + private partID(data: Record, kind: string): string { + const messageID = data.assistantMessageID as string | undefined + return messageID ? `${messageID}:${kind}:${(data.ordinal as number | undefined) ?? 0}` : "" + } + + private part(data: Record, kind: "text" | "reasoning", via: string, text: string): Event[] { + const messageID = data.assistantMessageID as string | undefined + const sessionID = data.sessionID as string | undefined + if (!messageID) return [] + const id = this.partID(data, kind) + if (!id) return [] + return [ + { + type: "message.part.updated", + properties: { + part: { + id, + sessionID, + messageID, + type: kind, + text, + time: { start: via.endsWith("started") ? Date.now() : undefined }, + }, + }, + }, + ] + } + + private toolPart(data: Record, via: string, state: NonNullable): Event[] { + const callID = data.id as string | undefined + if (!callID) return [] + const known = this.toolNames.get(callID) + if (data.name) this.toolNames.set(callID, String(data.name)) + const messageID = (data.assistantMessageID as string | undefined) ?? this.toolMessages.get(callID) + if (!messageID) return [] + this.toolMessages.set(callID, messageID) + const sessionID = (data.sessionID as string | undefined) ?? "" + return [ + { + type: "message.part.updated", + properties: { + part: { + id: `${messageID}:tool:${callID}`, + sessionID, + messageID, + type: "tool", + tool: v2ToolName((data.name as string | undefined) ?? known), + callID, + state, + time: { start: via.endsWith("started") ? Date.now() : undefined }, + }, + }, + }, + ] + } + + private permission(event: V2Event, data: Record): Event[] { + if (event.type === "permission.asked") { + const source = data.source as { messageID?: string; id?: string } | undefined + if (data.id && data.sessionID) rememberPermissionSession(String(data.id), String(data.sessionID)) + return [ + { + type: "permission.asked", + properties: { + id: data.id, + sessionID: data.sessionID, + permission: data.action, + patterns: data.resources ?? [], + metadata: { save: data.save ?? [] }, + tool: source?.messageID ? { messageID: source.messageID, callID: source.id } : undefined, + }, + }, + ] + } + if (event.type === "permission.replied") { + return [{ type: "permission.replied", properties: { sessionID: data.sessionID, requestID: data.requestID } }] + } + return [] + } + + private form(event: V2Event, data: Record): Event[] { + if (event.type === "form.created") { + const form = (data.form ?? data) as { + id?: string + sessionID?: string + fields?: Array<{ + key?: string + title?: string + description?: string + type?: string + options?: Array<{ value?: string; label?: string; description?: string }> + custom?: boolean + }> + } + if (form.id && form.sessionID) rememberFormSession(form.id, form.sessionID) + return [ + { + type: "question.asked", + properties: { + id: form.id, + sessionID: form.sessionID, + questions: (form.fields ?? []).map((field) => ({ + question: field.description ?? field.title ?? "", + header: field.title ?? "", + options: (field.options ?? []).map((option) => ({ + label: option.label ?? option.value ?? "", + description: option.description ?? "", + })), + multiple: field.type === "multiselect", + custom: field.custom ?? false, + })), + // 2.x keys answers by field key; the UI replies with positional + // answer arrays, so keep the key order for the reply mapping. + keys: (form.fields ?? []).map((field) => field.key), + tool: (form as { metadata?: { tool?: { messageID?: string; id?: string } } }).metadata?.tool + ? { + messageID: (form as { metadata: { tool: { messageID?: string } } }).metadata.tool.messageID, + callID: (form as { metadata: { tool: { id?: string } } }).metadata.tool.id, + } + : undefined, + }, + }, + ] + } + if (event.type === "form.replied" || event.type === "form.cancelled") { + return [{ type: "question.replied", properties: { sessionID: data.sessionID ?? (data.form as { sessionID?: string } | undefined)?.sessionID, requestID: (data.id ?? data.formID) as string } }] + } + return [] + } +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return text + } +} + +// --- HTTP -------------------------------------------------------------------- + +async function request(config: V2ClientConfig, path: string, init: RequestInit = {}, timeoutMs = 30_000): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + let response: Response + try { + response = await fetch(`${config.baseUrl}${path}`, { + ...init, + headers: { ...buildRequestHeaders(config), ...(init.headers as Record | undefined) }, + signal: init.signal ?? controller.signal, + }) + } finally { + clearTimeout(timer) + } + if (!response.ok) { + const body = await response.text() + throw (parseForgeState(response.status, response.headers, body) ?? apiErrorFor(response.status, `API Error: ${response.status} - ${body}`)) + } + if (response.status === 204) return undefined as T + const contentType = response.headers.get("content-type") || "" + // Never treat a 200 blindly as JSON: 2.x answers unknown paths with the web + // app's HTML. + if (!contentType.includes("application/json")) return undefined as T + return (await response.json()) as T +} + +function unwrap(body: unknown): T { + return (body as { data: T }).data +} + +// `location[directory]=` (deepObject) — the 2.x way to scope a request. Brackets +// must be percent-encoded. +function locationQuery(directory?: string): string { + if (!directory) return "" + const params = new URLSearchParams({ "location[directory]": directory }) + return params.toString() +} + +function withLocation(base: string, directory?: string): string { + const qs = locationQuery(directory) + if (!qs) return base + return `${base}${base.includes("?") ? "&" : "?"}${qs}` +} + +/** + * Build a client that speaks the opencode 2.x API. `events` is supplied by the + * caller (src/lib/sdk.ts) because streaming needs expo/fetch on native; every + * other call is plain JSON over the runtime `fetch`. + */ +export function createV2Client(config: V2ClientConfig, eventsFor: (signal?: AbortSignal) => AsyncGenerator): Client { + const directory = config.directory + + const formKeys = new Map() + const keysFor = async (formID: string, sessionID: string): Promise => { + const known = formKeys.get(formID) + if (known) return known + const body = await request<{ data?: Array<{ id?: string; fields?: Array<{ key?: string }> }> }>( + config, + withLocation("/api/form", directory), + ).catch(() => null) + const found = (body?.data ?? []).find((form) => form.id === formID) + const keys = (found?.fields ?? []).map((field) => field.key ?? "") + formKeys.set(formID, keys) + if (sessionID) rememberFormSession(formID, sessionID) + return keys + } + + return { + global: { + health: async (timeoutMs?: number) => { + const info = await request<{ version?: string }>(config, "/api/info", {}, timeoutMs) + return { healthy: true, version: info?.version ?? "" } + }, + + events: (signal) => eventsFor(signal), + + // A 2.x server is, by construction, 2.x — kept for the shared `Client` + // shape the dispatcher builds on. + detect: async () => "v2" as const, + }, + + project: { + list: async (): Promise => { + const body = await request(config, "/api/project") + const projects = unwrap>>(body) ?? [] + return projects.map((p) => mapProject(p)) + }, + current: async (): Promise => { + const body = await request(config, withLocation("/api/location", directory)) + const project = (body as { project?: Record })?.project + if (!project) throw new ApiError(404, "location has no project") + return mapProject(project) + }, + }, + + file: { + list: async ({ path = "." }: { path?: string } = {}): Promise => { + const params = new URLSearchParams({ path }) + const base = withLocation(`/api/fs/list?${params.toString()}`, directory) + const body = await request(config, base) + const entries = unwrap>(body) ?? [] + const root = directory ?? "" + return entries.map((entry) => { + const relative = (entry.path ?? "").replace(/^\//, "") + return { + name: relative.split("/").filter(Boolean).pop() ?? relative, + path: relative, + absolute: root ? `${root.replace(/\/+$/, "")}/${relative}` : relative, + type: entry.type === "directory" ? "directory" : "file", + ignored: false, + } satisfies FileEntry + }) + }, + // 2.x has no roots endpoint; callers fall back to manual path entry. + roots: async () => null, + }, + + vcs: { + get: async (): Promise => { + const body = await request(config, withLocation("/api/vcs", directory)) + const data = unwrap<{ provider?: string; branch?: { current?: string; default?: string } }>(body) + return { branch: data?.branch?.current, default_branch: data?.branch?.default } + }, + diff: async (mode: VcsMode): Promise => { + const params = new URLSearchParams({ mode: mode === "git" ? "working" : "branch" }) + const body = await request(config, withLocation(`/api/vcs/diff?${params.toString()}`, directory)) + return mapDiffs(unwrap(body) ?? []) + }, + }, + + // forge's own routes are identical across majors (issue #15) — the v2 + // client answers them the same way the 1.x client does. + forge: { + pr: async (dir: string, refresh?: boolean): Promise => { + const response = await fetch(`${config.baseUrl}${forgePrPath(dir, refresh)}`, { + headers: buildRequestHeaders(config), + }) + const body = await response.text() + const waking = parseForgeState(response.status, response.headers, body) + if (waking) throw waking + return readForgePr(response.status, body) + }, + }, + + path: { + get: async () => { + const body = await request(config, withLocation("/api/location", directory)) + const location = body as { directory?: string; project?: { canonical?: string; directory?: string } } + return { + home: "", + state: "", + config: "", + worktree: location?.project?.canonical ?? location?.directory ?? "", + directory: location?.directory ?? directory ?? "", + } + }, + }, + + session: { + list: async (): Promise => { + const sessions: Session[] = [] + let cursor: string | undefined + for (let page = 0; page < MAX_SESSION_PAGES; page++) { + const params = new URLSearchParams({ order: "desc", limit: "50" }) + if (cursor) { + params.delete("order") + params.set("cursor", String(cursor)) + } + const body = await request(config, withLocation(`/api/session?${params.toString()}`, directory)) + const pageData = (body ?? {}) as { data?: V2Session[]; cursor?: { previous?: string | null; next?: string | null } } + sessions.push(...(pageData.data ?? []).map(mapSession)) + cursor = pageData.cursor?.previous ?? undefined + if (!cursor) break + } + return sessions + }, + + get: async (sessionID: string) => mapSession(await request(config, `/api/session/${sessionID}`).then((body) => unwrap(body))), + + create: async (params?: { title?: string }) => { + const body = { ...(directory ? { location: { directory } } : {}), title: params?.title } + const created = await request(config, "/api/session", { method: "POST", body: JSON.stringify(body) }) + return mapSession(unwrap(created)) + }, + + delete: async (sessionID: string) => { + await request(config, `/api/session/${sessionID}`, { method: "DELETE" }) + }, + + update: async (sessionID: string, params: { title?: string; time?: { archived?: number } }) => { + await request(config, `/api/session/${sessionID}`, { method: "PATCH", body: JSON.stringify({ title: params.title }) }) + return mapSession(await request(config, `/api/session/${sessionID}`).then((body) => unwrap(body))) + }, + + // Newest-first + cursor pagination is 2.x's shape; the stores expect a + // plain, oldest-first array, so pages are followed and the order flipped. + messages: async (sessionID: string, params?: { limit?: number }) => { + const limit = params?.limit + const items: MessageWithParts[] = [] + const query = new URLSearchParams({ order: "desc" }) + if (limit) query.set("limit", String(limit)) + let cursor: string | undefined + for (let page = 0; page < MAX_MESSAGE_PAGES; page++) { + const query = new URLSearchParams({ order: "desc" }) + if (limit) query.set("limit", String(limit)) + if (cursor) { + // 2.x rejects a cursor combined with `order` (InvalidCursorError); + // the cursor already carries the direction it was taken in. + query.delete("order") + query.set("cursor", cursor) + } + const body = await request(config, `/api/session/${sessionID}/message?${query.toString()}`) + const pageData = (body ?? {}) as { data?: V2Message[]; cursor?: { previous?: string | null } } + // `order=desc` hands us newest-first; unshifting each entry turns + // that back into the oldest-first order the transcripts expect. + for (const entry of pageData.data ?? []) { + const mapped = mapMessage(entry, sessionID) + if (mapped) items.unshift(mapped) + } + cursor = pageData.cursor?.previous ?? undefined + if (limit || !cursor) break + } + return items + }, + + prompt: async (sessionID: string, params: Parameters[1]) => { + const text = params.parts.filter((p) => p.type === "text").map((p) => (p as { text: string }).text).join("\n") + const files = params.parts + .filter((p) => p.type === "file") + .map((p) => { + const file = p as unknown as { mime: string; url: string; filename?: string } + return { uri: file.url, ...(file.filename ? { name: file.filename } : {}) } + }) + await request(config, `/api/session/${sessionID}/prompt`, { + method: "POST", + body: JSON.stringify({ + text, + ...(files.length ? { files } : {}), + ...(params.agent ? { agents: [{ name: params.agent }] } : {}), + }), + }) + }, + + command: async (sessionID: string, params: Parameters[1]) => { + await request(config, `/api/session/${sessionID}/command`, { + method: "POST", + body: JSON.stringify({ name: params.command, arguments: params.arguments, agent: params.agent, model: params.model }), + }) + }, + + abort: async (sessionID: string) => { + await request(config, `/api/session/${sessionID}/interrupt`, { method: "POST" }) + return true + }, + + diff: async (sessionID: string, messageID?: string) => { + const params = new URLSearchParams() + if (messageID) params.set("from", messageID) + const qs = params.toString() + const body = await request(config, `/api/session/${sessionID}/diff${qs ? `?${qs}` : ""}`) + return mapDiffs(unwrap(body) ?? []) + }, + + // 1.x `revert` marked the message pending in one call and returned the + // session. 2.x splits staging from committing, so stage then read back + // the session (the app only ever stages, then re-prompts or unreverts). + revert: async (sessionID: string, messageID: string, partID?: string) => { + await request(config, `/api/session/${sessionID}/revert/stage`, { + method: "POST", + body: JSON.stringify(partID ? { messageID, partID } : { messageID }), + }) + const body = await request(config, `/api/session/${sessionID}`) + return mapSession(unwrap(body)) + }, + + unrevert: async (sessionID: string) => { + await request(config, `/api/session/${sessionID}/revert`, { method: "DELETE" }) + const body = await request(config, `/api/session/${sessionID}`) + return mapSession(unwrap(body)) + }, + }, + + permission: { + list: async (): Promise => { + const body = await request<{ data?: Array> }>(config, withLocation("/api/permission/request", directory)) + return (body?.data ?? []).map((item) => { + const source = item.source as { messageID?: string; id?: string } | undefined + const id = String(item.id) + const sessionID = String(item.sessionID) + rememberPermissionSession(id, sessionID) + return { + id, + sessionID, + permission: String(item.action ?? ""), + patterns: (item.resources as string[]) ?? [], + metadata: { save: item.save ?? [] }, + tool: source ? { messageID: source.messageID, callID: source.id } : undefined, + } + }) + }, + + reply: async (requestID: string, reply: "once" | "always" | "reject") => { + const sessionID = permissionSessions.get(requestID) + if (!sessionID) throw new ApiError(404, `unknown permission request ${requestID}`) + await request(config, `/api/session/${sessionID}/permission/${requestID}/reply`, { + method: "POST", + body: JSON.stringify({ decision: reply }), + }) + permissionSessions.delete(requestID) + return true + }, + }, + + question: { + list: async (): Promise => { + const body = await request<{ data?: Array> }>(config, withLocation("/api/form", directory)) + return (body?.data ?? []).map((form) => { + const fields = (form.fields as Array> | undefined) ?? [] + const id = String(form.id) + const sessionID = String(form.sessionID) + rememberFormSession(id, sessionID) + const keys = fields.map((field) => (field.key as string) ?? "") + formKeys.set(id, keys) + return { + id, + sessionID, + questions: fields.map((field) => ({ + question: (field.description as string) ?? (field.title as string) ?? "", + header: (field.title as string) ?? "", + options: ((field.options as Array>) ?? []).map((option) => ({ + label: option.label ?? option.value ?? "", + description: option.description ?? "", + })), + multiple: field.type === "multiselect", + custom: field.custom === true, + })), + keys, + tool: (form.metadata as { tool?: { messageID?: string; id?: string } } | undefined)?.tool, + } + }) + }, + + reply: async (requestID: string, answers: string[][]) => { + const sessionID = formSessions.get(requestID) + if (!sessionID) throw new ApiError(404, `unknown form ${requestID}`) + const keys = await keysFor(requestID, sessionID) + const answer: Record = {} + answers.forEach((values, index) => { + const key = keys[index] ?? `q${index}` + answer[key] = values.length > 1 ? values : values[0] ?? "" + }) + await request(config, `/api/session/${sessionID}/form/${requestID}/reply`, { + method: "POST", + body: JSON.stringify({ answer }), + }) + formSessions.delete(requestID) + return true + }, + + reject: async (requestID: string) => { + const sessionID = formSessions.get(requestID) + if (!sessionID) throw new ApiError(404, `unknown form ${requestID}`) + await request(config, `/api/session/${sessionID}/form/${requestID}`, { method: "DELETE" }) + formSessions.delete(requestID) + return true + }, + }, + + agent: { + list: async (): Promise => { + const body = await request(config, withLocation("/api/agent", directory)) + return (unwrap>>(body) ?? []).map((agent) => ({ + name: String(agent.id ?? agent.name ?? ""), + description: agent.description as string | undefined, + mode: (agent.mode as Agent["mode"]) ?? "primary", + hidden: agent.hidden as boolean | undefined, + native: agent.native as boolean | undefined, + options: {}, + })) + }, + }, + + command: { + list: async (): Promise => { + const body = await request(config, withLocation("/api/command", directory)) + return (unwrap>>(body) ?? []).map((command) => ({ + name: String(command.name ?? ""), + description: command.description as string | undefined, + template: "", + hints: [], + })) + }, + }, + + provider: { + list: async () => { + const [providerBody, modelBody] = await Promise.all([ + request(config, withLocation("/api/provider", directory)), + request(config, withLocation("/api/model", directory)).catch(() => null), + ]) + const providers = unwrap>>(providerBody) ?? [] + const models = unwrap>>(modelBody) ?? [] + const defaultBody = await request(config, withLocation("/api/model/default", directory)).catch(() => null) + const defaults = unwrap<{ modelID?: string; providerID?: string }>(defaultBody) + + return { + all: providers.map((provider) => { + const owned: Record = {} + for (const model of models.filter((m) => m.providerID === provider.id)) { + owned[String(model.modelID ?? model.id)] = mapV2Model(model) + } + return { + id: String(provider.id), + name: String(provider.name ?? provider.id), + models: owned, + } + }), + default: defaults?.providerID && defaults.modelID ? { [defaults.providerID]: defaults.modelID } : {}, + connected: providers.filter((p) => models.some((m) => m.providerID === p.id)).map((p) => String(p.id)), + } + }, + }, + + config: { + get: async () => await request(config, "/api/config"), + }, + } +} + +function mapProject(project: Record): Project { + const canonical = (project.canonical as string) ?? (project.directory as string) ?? (project.worktree as string) ?? "" + return { + id: String(project.id ?? ""), + name: canonical.split("/").filter(Boolean).pop() ?? canonical, + worktree: canonical, + vcs: project.vcs === "git" ? "git" : undefined, + time: project.time as Project["time"], + } +} + +function mapDiffs(diffs: FileDiff[]): FileDiff[] { + return (diffs ?? []).map((diff) => ({ + file: diff.file, + patch: diff.patch, + additions: diff.additions ?? 0, + deletions: diff.deletions ?? 0, + status: diff.status, + })) +} + +interface V2ModelShape { + id: string + name: string + attachment: boolean + reasoning: boolean + tool_call: boolean + limit: { context: number; output: number } + status?: "alpha" | "beta" | "deprecated" | "active" + variants?: Record +} + +function mapV2Model(model: Record): V2ModelShape { + const capabilities = (model.capabilities as { input?: string[]; output?: string[]; tools?: boolean } | undefined) ?? {} + const variants = (model.variants as Array<{ id?: string; settings?: Record }> | undefined) ?? [] + const variantMap: Record = {} + for (const variant of variants) { + if (!variant?.id) continue + variantMap[String(variant.id)] = { reasoningEffort: variant.settings?.reasoningEffort as string | undefined } + } + const id = String(model.modelID ?? model.id) + const limit = (model.limit as { context?: number; output?: number } | undefined) ?? {} + return { + id, + name: String(model.name ?? id), + attachment: (capabilities.input ?? []).includes("image"), + reasoning: variants.some((variant) => variant?.settings && "reasoningEffort" in variant.settings), + tool_call: capabilities.tools === true, + limit: { context: limit.context ?? 0, output: limit.output ?? 0 }, + variants: variantMap, + } +} diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index 28442912e..d4f23c4b2 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -5,13 +5,16 @@ import { fetch as expoFetch } from "expo/fetch" import { buildRequestHeaders } from "./headers" import { SSEParser } from "./sse" -import { apiErrorFor } from "./api-error" +import { apiErrorFor, ApiError } from "./api-error" import { parseForgeState } from "./forge" import { forgePrPath, readForgePr, type ForgePr } from "./forge-pr" import { loadSessionList } from "./session-list" +import { detectServerMajor, type ServerMajor } from "./server-version" +import { createV2Client, V2EventTranslator } from "./sdk-v2" import type { FileRoot } from "./file-roots" export { ApiAuthError, isAuthError } from "./api-error" +export { ApiError } from "./api-error" export { WorkspaceWakingError, isWorkspaceWakingError } from "./forge" export interface ClientConfig { @@ -200,20 +203,38 @@ export interface HealthResponse { version: string } -const REQUEST_TIMEOUT_MS = 30_000 +// A pending permission request, as both API families expose it. 1.x answers +// `tool`/`input`; 2.x answers `action`/`resources`, which the adapter maps onto +// the same `permission`/`patterns` fields the UI already renders +// (PermissionPrompt.tsx). +export interface PermissionRequest { + id: string + sessionID: string + permission: string + patterns: string[] + metadata?: Record + tool?: { messageID?: string; callID?: string } +} -// Thrown by request() on a non-2xx response. Carries the HTTP status so -// callers can distinguish e.g. 404 (older server, endpoint missing) from -// other failures without parsing the message string. -export class ApiError extends Error { - status: number - constructor(status: number, body: string) { - super(`API Error: ${status} - ${body}`) - this.name = "ApiError" - this.status = status - } +// A pending question. 1.x calls them questions, 2.x calls them forms; both map +// onto this shape. `keys` carries the 2.x answer keys so replies can be keyed +// correctly (the UI replies positionally). +export interface QuestionRequest { + id: string + sessionID: string + questions: Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiple?: boolean + custom?: boolean + }> + keys?: string[] + tool?: { messageID?: string; callID?: string } } +const REQUEST_TIMEOUT_MS = 30_000 + function createHeaders(config: ClientConfig): HeadersInit { return buildRequestHeaders(config) } @@ -277,7 +298,7 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutM } } -export function createClient(config: ClientConfig) { +export function createV1Client(config: ClientConfig) { // Normalize once: a trailing slash on baseUrl (e.g. pasted into Advanced // mode or the Edit screen) would otherwise survive into every // `${config.baseUrl}${path}` concatenation below as a double slash, which @@ -291,6 +312,10 @@ export function createClient(config: ClientConfig) { // onboarding connection test to fail fast on a bad/unreachable IP // instead of hanging for the full 30s (issue: first-run bounce). health: (timeoutMs?: number) => request(config, "/global/health", {}, timeoutMs), + + // Probe of which API family this server speaks, cached per server. + // `force` re-probes — used by the SSE reconnect loop. See ./server-version. + detect: async (force?: boolean) => await detectServerMajor(config, { force }), // SSE event stream - returns async iterator // Pass an AbortSignal to cancel the connection async *events(signal?: AbortSignal): AsyncGenerator { @@ -529,8 +554,7 @@ export function createClient(config: ClientConfig) { }, permission: { - list: () => - request>(config, "/permission"), + list: () => request(config, "/permission"), reply: (requestID: string, reply: "once" | "always" | "reject") => request(config, `/permission/${requestID}/reply`, { @@ -540,7 +564,7 @@ export function createClient(config: ClientConfig) { }, question: { - list: () => request>(config, "/question"), + list: () => request(config, "/question"), reply: (requestID: string, answers: string[][]) => request(config, `/question/${requestID}/reply`, { @@ -594,4 +618,151 @@ export function createClient(config: ClientConfig) { } } -export type Client = ReturnType +export type Client = ReturnType + +// SSE stream against the 2.x `/api/event` endpoint. 2.x frames are +// `data: {"id","created","type","location","data"}` plus `: heartbeat` comments, +// and every event is granular (a `session.text.delta` is one chunk, not the +// whole part). V2EventTranslator accumulates them into the complete +// `message.part.updated`-style events the stores already dispatch on. +async function* eventsV2(config: ClientConfig, signal?: AbortSignal): AsyncGenerator { + const translator = new V2EventTranslator() + const url = `${config.baseUrl}/api/event` + const headers = createHeaders(config) + delete (headers as Record)["Content-Type"] + + const response = await expoFetch(url, { headers, signal }) + if (!response.ok || !response.body) { + const waking = parseForgeState(response.status, response.headers, "") + if (waking) throw waking + throw apiErrorFor(response.status, `Failed to connect to event stream: ${response.status}`) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + const parser = new SSEParser() + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + console.log("[SSE] v2 stream ended") + break + } + for (const data of parser.push(decoder.decode(value, { stream: true }))) { + let raw: unknown + try { + raw = JSON.parse(data) + } catch (err) { + console.warn("[SSE] Failed to parse v2 event", { + length: data.length, + error: err instanceof Error ? err.message : String(err), + }) + continue + } + const event = raw as { id?: string; created?: number; type: string; data?: Record } + for (const mapped of translator.translate(event)) yield mapped + } + } + } finally { + reader.releaseLock() + } +} + +/** + * Build a client for `config.baseUrl`, transparently speaking the 1.x or the + * 2.x API. + * + * The first call probes `GET /api/info` (see ./server-version) and every + * request afterwards goes to whichever implementation answered for that + * server, so the 1.x path keeps working exactly as before and 2.x workspaces + * need nothing special from the screens or stores. `global.detect(true)` is + * called by the SSE reconnect loop so a server upgraded in place is picked up + * without restarting the app. + */ +export function createClient(config: ClientConfig): Client { + const scoped: ClientConfig = { ...config, baseUrl: config.baseUrl.replace(/\/+$/, "") } + const v1 = createV1Client(scoped) + let current: { major: ServerMajor; client: Client } | null = null + + const resolve = async (force = false): Promise => { + if (force || !current) { + const major = await detectServerMajor(scoped, { force }) + current = { + major, + client: major === "v2" ? createV2Client(scoped, (signal) => eventsV2(scoped, signal)) : v1, + } + } + return current.client + } + + return { + global: { + health: (timeoutMs?: number) => resolve().then((c) => c.global.health(timeoutMs)), + events: async function* (signal?: AbortSignal) { + const client = await resolve() + yield* client.global.events(signal) + }, + detect: async (force?: boolean) => { + await resolve(force) + return (await current)?.major ?? "v1" + }, + }, + project: { + list: () => resolve().then((c) => c.project.list()), + current: () => resolve().then((c) => c.project.current()), + }, + file: { + list: (params?: { path?: string }) => resolve().then((c) => c.file.list(params)), + roots: () => resolve().then((c) => c.file.roots()), + }, + vcs: { + get: () => resolve().then((c) => c.vcs.get()), + diff: (mode: VcsMode) => resolve().then((c) => c.vcs.diff(mode)), + }, + forge: { + pr: (directory: string, refresh?: boolean) => resolve().then((c) => c.forge.pr(directory, refresh)), + }, + path: { + get: () => resolve().then((c) => c.path.get()), + }, + session: { + list: (params?: { roots?: boolean; limit?: number; search?: string }) => resolve().then((c) => c.session.list(params)), + get: (sessionID: string) => resolve().then((c) => c.session.get(sessionID)), + create: (params?: { title?: string }) => resolve().then((c) => c.session.create(params)), + delete: (sessionID: string) => resolve().then((c) => c.session.delete(sessionID)), + update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) => + resolve().then((c) => c.session.update(sessionID, params)), + messages: (sessionID: string, params?: { limit?: number }) => resolve().then((c) => c.session.messages(sessionID, params)), + prompt: (sessionID: string, params: Parameters[1]) => + resolve().then((c) => c.session.prompt(sessionID, params)), + command: (sessionID: string, params: Parameters[1]) => + resolve().then((c) => c.session.command(sessionID, params)), + abort: (sessionID: string) => resolve().then((c) => c.session.abort(sessionID)), + diff: (sessionID: string, messageID?: string) => resolve().then((c) => c.session.diff(sessionID, messageID)), + revert: (sessionID: string, messageID: string, partID?: string) => + resolve().then((c) => c.session.revert(sessionID, messageID, partID)), + unrevert: (sessionID: string) => resolve().then((c) => c.session.unrevert(sessionID)), + }, + permission: { + list: () => resolve().then((c) => c.permission.list()), + reply: (requestID: string, reply: "once" | "always" | "reject") => resolve().then((c) => c.permission.reply(requestID, reply)), + }, + question: { + list: () => resolve().then((c) => c.question.list()), + reply: (requestID: string, answers: string[][]) => resolve().then((c) => c.question.reply(requestID, answers)), + reject: (requestID: string) => resolve().then((c) => c.question.reject(requestID)), + }, + agent: { + list: () => resolve().then((c) => c.agent.list()), + }, + command: { + list: () => resolve().then((c) => c.command.list()), + }, + provider: { + list: () => resolve().then((c) => c.provider.list()), + }, + config: { + get: () => resolve().then((c) => c.config.get()), + }, + } +} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index 15495cc4d..cc44cdcd3 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -23,7 +23,10 @@ export function captureException( } export function captureDiagnostic(report: DiagnosticReport) { - log.info("diag", "connect failed", report.classification) + // "connect failed" was logged for every report, including ones that ended in + // `ok` — so a shared report could read "connect failed ok" and look broken + // while describing a working connection. + log.info("diag", report.classification === "ok" ? "connect succeeded" : "connect failed", report.classification) } function toError(value: unknown): Error { diff --git a/src/lib/server-version.ts b/src/lib/server-version.ts new file mode 100644 index 000000000..52a28acfd --- /dev/null +++ b/src/lib/server-version.ts @@ -0,0 +1,111 @@ +// Per-connection server major-version detection. +// +// The same URL is answered by two incompatible API families: opencode 1.x +// exposes `/global/health`, `/session/...`; opencode 2.x moved everything under +// `/api/*`. The only cheap, reliable discriminator is `GET /api/info`: +// +// * a 2.x server answers `application/json` with `{"version":"2.0.29",...}` +// * a 1.x server answers 200 with its web app's HTML (checked on 1.18.32) +// +// So the decision is made on the content type + parse result, never on the +// status code — and anything that is not 2.x JSON is treated as 1.x. +import { buildRequestHeaders } from "./headers.ts" + +export type ServerMajor = "v1" | "v2" + +export interface VersionProbeConfig { + baseUrl: string + auth?: { username: string; password: string } +} + +// Cached per server. `baseUrl` alone is not enough: the same host can be +// migrated in place, and a connection edited to point at a different major on +// the same URL must re-probe. +const cache = new Map() + +// How long the probe waits for /api/info before it is treated as unreachable. +const PROBE_TIMEOUT_MS = 10_000 + +function key(config: VersionProbeConfig): string { + return `${config.baseUrl.replace(/\/+$/, "")}::${config.auth?.username ?? ""}:${config.auth?.password ?? ""}` +} + +/** + * Decide whether `baseUrl` speaks the 2.x API. Falls back to "v1" for anything + * that is not 2.x JSON (HTML page, unknown shape, network failure), so a + * half-working probe can never hide a 1.x server. + */ +export async function detectServerMajor( + config: VersionProbeConfig, + init: { force?: boolean; timeoutMs?: number } = {}, +): Promise { + const id = key(config) + if (!init.force) { + const cached = cache.get(id) + if (cached) return cached + } + + const verdict = await attempt(config, init.timeoutMs ?? PROBE_TIMEOUT_MS) + // A workspace that is only still starting gives no verdict — do not cache + // "v1" for it, or it would keep the wrong API family after it wakes. + if (verdict !== "waking") cache.set(id, verdict) + return verdict === "waking" ? cache.get(id) ?? "v1" : verdict +} + +export function clearServerMajorCache(): void { + cache.clear() +} + +export function cachedServerMajor(config: VersionProbeConfig): ServerMajor | undefined { + return cache.get(key(config)) +} + +async function attempt(config: VersionProbeConfig, timeoutMs: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${config.baseUrl.replace(/\/+$/, "")}/api/info`, { + headers: buildRequestHeaders({ auth: config.auth }), + signal: controller.signal, + }) + // A forge front that is still starting the workspace answers everything + // itself; that says nothing about the server behind it. + if (isWaking(response.status, response.headers)) return "waking" + return majorFromResponse(response.headers.get("content-type"), await readBody(response)) + } catch { + return "v1" + } finally { + clearTimeout(timer) + } +} + +/** + * Decide the API family from a `GET /api/info` answer, using only the content + * type and the parsed version — never the status code. Both majors answer 200 + * to unknown paths (2.x serves its SPA, 1.x its web app), and a 2.x server can + * return its JSON version route with an error status, so status proves nothing. + */ +export function majorFromResponse(contentType: string | null, body: unknown): ServerMajor { + if (!isJsonContentType(contentType)) return "v1" + const version = (body as { version?: unknown })?.version + return /^([2-9])\./.test(typeof version === "string" ? version : "") ? "v2" : "v1" +} + +export function isJsonContentType(contentType?: string | null): boolean { + return (contentType ?? "").includes("application/json") +} + +// A 1.x server returns its HTML page here, so the body may not be JSON at all. +async function readBody(response: Response): Promise { + const text = await response.text() + try { + return JSON.parse(text) as unknown + } catch { + return null + } +} + +function isWaking(status: number, headers: Headers): boolean { + if (status !== 503) return false + return headers.get("x-forge-state") !== null +} diff --git a/src/stores/connections.ts b/src/stores/connections.ts index 6af19f7d5..578b35a1e 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -92,7 +92,7 @@ interface ConnectionsState { connection: ServerConnection, source: ConnectionTestSource, password?: string, - ) => Promise<{ ok: boolean; error?: string }> + ) => Promise<{ ok: boolean; error?: string; wakeSeconds?: number }> updateConnection: (id: string, updates: Partial, password?: string) => Promise refreshProject: () => Promise // Create a one-off client pointing at a specific directory (for cross-project operations). @@ -283,19 +283,32 @@ export const useConnections = create((set, get) => ({ testConnection: async (connection, source, password) => { track(AnalyticsEvent.ConnectionAttempted, { source }) + const client = createClient({ + baseUrl: connection.url, + directory: connection.directory, + auth: buildAuth(connection.username, password), + }) + + // A sleeping forge workspace answers 503 + X-Forge-State. That is not a + // failed connection — the probe itself wakes the server — so keep probing + // inside the same budget. Reporting it as a failure made the "Test + // connection" button show a scary report that then said the connection + // works (it does; the report ran a second later, against the woken server). + let wakeSeconds: number | undefined try { - const client = createClient({ - baseUrl: connection.url, - directory: connection.directory, - auth: buildAuth(connection.username, password), + await untilAwake(() => client.global.health(CONNECTION_TEST_TIMEOUT_MS), { + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + deadlineMs: CONNECTION_TEST_TIMEOUT_MS, + onState: (info) => { + wakeSeconds = info.wakeSeconds + }, }) - - await client.global.health(CONNECTION_TEST_TIMEOUT_MS) track(AnalyticsEvent.ConnectionSucceeded, { source }) return { ok: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) track(AnalyticsEvent.ConnectionFailed, { source, error_class: classifyConnectionError(message) }) + if (isWorkspaceWakingError(error)) return { ok: false, error: message, wakeSeconds } return { ok: false, error: message } } }, diff --git a/src/stores/events.ts b/src/stores/events.ts index 07dafdb5b..7b92d6ec9 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -10,7 +10,7 @@ import { recordSuccessfulSession } from "../lib/store-review" import { isAuthError } from "../lib/api-error" import { isWorkspaceWakingError } from "../lib/forge" import { isSessionActuallyIdle } from "../lib/session-status-reconcile" -import type { Client, Part, Session, Message } from "../lib/sdk" +import type { Client, Part, Session, Message, PermissionRequest, QuestionRequest } from "../lib/sdk" // Session status from the server type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string } @@ -29,32 +29,8 @@ interface EventsState { sessionStatus: Record statusText: Record // Permissions & questions (pending per session) - permissions: Record< - string, - Array<{ - id: string - sessionID: string - permission: string - patterns: string[] - metadata: Record - tool?: { messageID: string; callID: string } - }> - > - questions: Record< - string, - Array<{ - id: string - sessionID: string - questions: Array<{ - question: string - header: string - options: Array<{ label: string; description: string }> - multiple?: boolean - custom?: boolean - }> - tool?: { messageID: string; callID: string } - }> - > + permissions: Record + questions: Record connect: () => void disconnect: () => void @@ -79,11 +55,11 @@ const PROLONGED_DISCONNECT_MS = 30_000 export async function refreshPending(client: Client, sessionID: string) { try { const [perms, questions] = await Promise.all([client.permission.list(), client.question.list()]) - const sessionPerms = (perms || []).filter((p: Record) => p.sessionID === sessionID) - const sessionQuestions = (questions || []).filter((q: Record) => q.sessionID === sessionID) + const sessionPerms = (perms || []).filter((p) => p.sessionID === sessionID) + const sessionQuestions = (questions || []).filter((q) => q.sessionID === sessionID) useEvents.setState((state) => ({ - permissions: { ...state.permissions, [sessionID]: sessionPerms as any }, - questions: { ...state.questions, [sessionID]: sessionQuestions as any }, + permissions: { ...state.permissions, [sessionID]: sessionPerms }, + questions: { ...state.questions, [sessionID]: sessionQuestions }, })) } catch (err) { console.warn("[Events] Failed to refresh pending:", err) @@ -182,6 +158,12 @@ export const useEvents = create((set, get) => ({ // failed retries can't re-arm the check on every attempt. const isReconnect = get().reconnectAttempts > 0 let resyncedAfterReconnect = false + // A reconnect is the point where a server may have been upgraded (1.x -> + // 2.x) underneath a sleeping app: re-run the version probe first so this + // stream (and everything after it) talks to the right API family. + if (isReconnect) { + await client.global.detect(true).catch((err) => console.warn("[SSE] version re-detect failed:", err)) + } const stableTimer = setTimeout(() => { if (!currentController.signal.aborted) { set({ reconnectAttempts: 0, lastDisconnectAt: null }) @@ -338,6 +320,16 @@ export const useEvents = create((set, get) => ({ break } + case "session.revert": { + // 2.x commits or clears a pending revert on its own (the next + // prompt commits it), so the app learns about it only from the + // stream. Patching just the marker keeps the rest of the session. + const sessionID = props.sessionID as string + if (!sessionID) break + useSessions.getState().handleEvent({ type, properties: props } as any) + break + } + case "session.updated": { const info = props.info as Session | undefined if (!info) break diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index c28bb93d4..4380b8409 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -507,6 +507,33 @@ export const useSessions = create((set, get) => ({ break } + // Only the 2.x stream emits this: commit/clear happen server-side without + // an API call from the app, so this is the only way the pending-revert + // marker (and the transcript filtering that follows it) stays honest. + case "session.revert": { + const sessionID = props.sessionID as string + if (!sessionID) return + const revert = props.revert as Session["revert"] + // A commit also drops the edited message and everything after it, and + // 2.x does that without naming messages — `droppedFrom` is the first + // dropped id, and the same id comparison the transcript filter uses + // decides what goes. Without this the pre-edit exchange would reappear + // next to the new one the moment the marker clears. + const droppedFrom = props.droppedFrom as string | undefined + const drops = droppedFrom !== undefined && get().currentSession?.id === sessionID + set((state) => { + const messages = drops ? state.messages.filter((m) => m.id.startsWith("temp-") || m.id < droppedFrom) : state.messages + const keep = new Set(messages.map((m) => m.id)) + return { + messages, + parts: drops ? Object.fromEntries(Object.entries(state.parts).filter(([id]) => keep.has(id))) : state.parts, + sessions: state.sessions.map((s) => (s.id === sessionID ? { ...s, revert } : s)), + currentSession: state.currentSession?.id === sessionID ? { ...state.currentSession, revert } : state.currentSession, + } + }) + break + } + case "session.updated": { const session = (props.info || props) as Session | undefined if (!session?.id) return diff --git a/tests/fixtures/mock-opencode-server-v2.ts b/tests/fixtures/mock-opencode-server-v2.ts new file mode 100644 index 000000000..e72d9c85e --- /dev/null +++ b/tests/fixtures/mock-opencode-server-v2.ts @@ -0,0 +1,663 @@ +// opencode **2.x** protocol stub for the adapter tests. +// +// This is the 2.x counterpart of mock-opencode-server.ts: same idea (a real +// HTTP/SSE server that only implements what the client talks to), but speaking +// the newer protocol — `/api/*` routes, `{"data": …}` envelopes, +// `?location[directory]=` scoping, newest-first cursor-paginated message lists, +// and the granular `session.*` event stream with `session.text.delta` / +// `session.reasoning.delta` / `session.tool.*` deltas. +// +// Shapes come from the captures under docs/opencode-v2-port/ +// (openapi-2.0.29.json, sample-messages.json, sample-session.json, +// sample-events.sse), so the adapter is tested against real 2.0.29 bytes rather +// than a guess of them. The one thing the captures could not include — pending +// permission requests and forms — comes from a live 2.0.29 server (see +// sample-permission-events.sse / sample-form-events.sse). +import type { IncomingMessage, ServerResponse } from "node:http" +import { randomUUID } from "node:crypto" + +export interface V2MockOptions { + port: number + failAuth?: boolean + replyText?: string + replyDelayMs?: number +} + +// The directory the 2.x mock scopes its seeded session to. Matches the +// captured sample session's location. +export const V2_DIRECTORY = "/home/agent/work/ruby-dcb" + +export const V2_SESSION_ID = "ses_mockv2000000000000000000" + +interface V2MessageEntry { + id: string + type: string + time?: Record + text?: string + outcome?: string + agent?: string + model?: { id: string; providerID: string } + content?: Array> + finish?: string + rawFinish?: string + cost?: number + tokens?: Record + snapshot?: { start: string; end: string; files: unknown[] } +} + +interface PendingPermission { + id: string + sessionID: string + action: string + resources: string[] + save: string[] + source: { type: "tool"; messageID: string; id: string } +} + +interface PendingForm { + id: string + sessionID: string + title: string + metadata: Record + fields: Array> +} + +const V2_VERSION = "2.0.29-mock" +// How many transcript entries an unlimited GET /message hands back, so the +// cursor path gets exercised the way a real 2.x server exercises it. +const MESSAGE_PAGE_SIZE = 4 + +const DEFAULT_REPLY_TEXT = "14 — the number of entries. (mock v2 reply)" + +/** + * Wire a 2.x API surface onto an existing node http server. The caller owns + * the server's lifecycle; this only installs a request handler. + */ +export function installV2Api(server: import("node:http").Server, options: V2MockOptions) { + const { port, failAuth = false, replyText = DEFAULT_REPLY_TEXT, replyDelayMs = 30 } = options + + const sessions = new Map; messages: V2MessageEntry[] }>() + const sse = new Set() + const permissions = new Map() + const forms = new Map() + let seq = 0 + + // The session every scripted reply/permission/form is attached to, so tests + // can rely on one known id instead of creating one first. + seedSession(V2_SESSION_ID, V2_DIRECTORY, "mock v2 session") + + function seedSession(id: string, directory: string, title: string) { + const now = Date.now() + sessions.set(id, { + data: { + id, + projectID: "mock-project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: now, updated: now }, + title, + location: { directory }, + }, + messages: [], + }) + } + + function nextID(prefix: string): string { + seq += 1 + return `${prefix}_${Date.now().toString(36)}${seq.toString(36)}` + } + + function broadcast(event: Record) { + const line = `data: ${JSON.stringify(event)}\n\n` + for (const res of sse) { + try { + res.write(line) + } catch { + sse.delete(res) + } + } + } + + function emit(type: string, data: Record, directory = V2_DIRECTORY) { + broadcast({ + id: nextID("evt"), + created: Date.now(), + type, + location: { directory }, + data, + durable: { aggregateID: data.sessionID ?? type, seq, version: 1 }, + }) + } + + function locationDirectory(url: URL): string { + return url.searchParams.get("location[directory]") || V2_DIRECTORY + } + + function json(res: ServerResponse, status: number, body: unknown) { + const data = JSON.stringify(body) + res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }) + res.end(data) + } + + function data(res: ServerResponse, body: unknown, extra: Record = {}) { + json(res, 200, { location: { directory: V2_DIRECTORY }, data: body, ...extra }) + } + + function messages(sessionID: string): V2MessageEntry[] { + return sessions.get(sessionID)?.messages ?? [] + } + + // The whole point of the v2 stream: one text reply is delivered as several + // granular events, not a single whole-part update. + function scriptAssistantReply(sessionID: string, directory: string) { + const assistantID = nextID("msg") + const userText = "Run `ls` and give me the count." + const step = { sessionID, assistantMessageID: assistantID } + + emit("session.execution.started", step, directory) + + const entries = messages(sessionID) + entries.unshift({ id: nextID("msg"), time: { created: Date.now() }, type: "user", text: userText }) + + emit("session.inbox.enqueued", { + inboxID: entries[0].id, + sessionID, + item: { type: "user", payload: { text: userText }, delivery: "steer" }, + }, directory) + emit("session.inbox.delivered", { sessionID, inboxID: entries[0].id }, directory) + + setTimeout(() => { + const started = Date.now() + emit( + "session.step.started", + { sessionID, agent: "build", model: { id: "mock-model", providerID: "mock" }, assistantMessageID: assistantID, started }, + directory, + ) + + const reasoningChunks = ["The user wants a count. ", "I will answer directly."] + emit("session.reasoning.started", { ...step, ordinal: 0, state: { reasoningField: "reasoning_content" } }, directory) + for (const chunk of reasoningChunks) { + emit("session.reasoning.delta", { ...step, ordinal: 0, delta: chunk }, directory) + } + const reasoningText = reasoningChunks.join("") + emit("session.reasoning.ended", { ...step, ordinal: 0, text: reasoningText, state: { reasoningField: "reasoning_content" } }, directory) + + // A tool round trip, so the adapter's tool-part mapping is exercised too. + const callID = nextID("call") + emit("session.tool.input.started", { ...step, id: callID, name: "shell" }, directory) + emit("session.tool.input.ended", { ...step, id: callID, text: '{"command":"ls"}' }, directory) + emit("session.tool.called", { ...step, id: callID, input: { command: "ls" }, executed: false }, directory) + emit("session.tool.success", { + ...step, + id: callID, + content: [{ type: "text", text: "CLAUDE.md\nGemfile\nREADME.md\n" }], + metadata: { status: "completed", truncated: false, exit: 0 }, + executed: false, + }, directory) + + const textChunks = replyText.match(/.{1,12}/g) ?? [replyText] + emit("session.text.started", { ...step, ordinal: 0 }, directory) + for (const chunk of textChunks) { + emit("session.text.delta", { ...step, ordinal: 0, delta: chunk }, directory) + } + emit("session.text.ended", { ...step, ordinal: 0, text: replyText }, directory) + + emit("session.step.streamed", step, directory) + emit( + "session.step.ended", + { + sessionID, + assistantMessageID: assistantID, + finish: "stop", + rawFinish: "stop", + cost: 0, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 0, write: 0 } }, + snapshot: "mock-snapshot", + files: [], + }, + directory, + ) + emit("session.execution.succeeded", { sessionID }, directory) + + const content = [ + { type: "reasoning", text: reasoningText, state: { reasoningField: "reasoning_content" }, time: { created: started } }, + { + type: "tool", + id: callID, + name: "shell", + executed: false, + state: { + status: "completed", + input: { command: "ls" }, + content: [{ type: "text", text: "CLAUDE.md\nGemfile\nREADME.md\n" }], + metadata: { status: "completed", truncated: false, exit: 0 }, + }, + time: { created: started, ran: started + 5, completed: started + 10 }, + }, + { type: "text", text: replyText }, + ] + entries.unshift({ + id: assistantID, + time: { created: started, streamed: Date.now(), completed: Date.now() }, + type: "assistant", + agent: "build", + model: { id: "mock-model", providerID: "mock" }, + content, + finish: "stop", + rawFinish: "stop", + cost: 0, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 0, write: 0 } }, + snapshot: { start: "mock-snapshot", end: "mock-snapshot", files: [] }, + }) + entries.unshift({ id: nextID("msg"), time: { created: Date.now() }, type: "idle", outcome: "succeeded" }) + }, replyDelayMs) + } + + /** + * Emit a `permission.asked` event for `sessionID` and keep the request + * pending until the client replies (or `settlePermission` is called). + */ + function askPermission(sessionID = V2_SESSION_ID, action = "shell", resource = "rm -rf /tmp/mock") { + const request: PendingPermission = { + id: nextID("per"), + sessionID, + action, + resources: [resource], + save: ["rm -rf /tmp/*"], + source: { type: "tool", messageID: nextID("msg"), id: nextID("call") }, + } + permissions.set(request.id, request) + emit("permission.asked", { + id: request.id, + sessionID, + action: request.action, + resources: request.resources, + save: request.save, + source: request.source, + }) + return request.id + } + + /** + * Emit a `form.created` event (the 2.x name for a question) and keep the form + * pending until the client replies. + */ + function askForm(sessionID = V2_SESSION_ID) { + const form: PendingForm = { + id: nextID("frm"), + sessionID, + title: "Questions", + metadata: { kind: "question", tool: { messageID: nextID("msg"), id: nextID("call") } }, + fields: [ + { + key: "q0", + title: "Logo color", + description: "What color should the logo be?", + type: "string", + options: [ + { value: "Red", label: "Red", description: "Use red for the logo." }, + { value: "Blue", label: "Blue", description: "Use blue for the logo." }, + ], + custom: true, + }, + ], + } + forms.set(form.id, form) + emit("form.created", { form }) + return form.id + } + + function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => resolve(body)) + }) + } + + // Mirrors how a 2.x server answers unknown routes: the web app's HTML, with a + // 200. Tests must never mistake that for JSON. + function html(res: ServerResponse, status: number) { + const body = "opencode 2 mock web app" + res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(body) }) + res.end(body) + } + + server.on("request", async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url || "/", `http://localhost:${port}`) + const path = url.pathname + const method = req.method || "GET" + + if (failAuth) { + res.setHeader("WWW-Authenticate", 'Basic realm="Secure Area"') + return json(res, 401, { error: "Unauthorized", message: "mock-opencode-server: --fail-auth" }) + } + + if (method === "GET" && path === "/api/info") { + return json(res, 200, { + version: V2_VERSION, + pid: process.pid, + urls: [`http://127.0.0.1:${port}`], + paths: { tmp: "/tmp/opencode-mock" }, + capabilities: { persistentPty: true }, + }) + } + + if (method === "GET" && path === "/api/event") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }) + res.write(`data: ${JSON.stringify({ id: nextID("evt"), type: "server.connected", data: {} })}\n\n`) + sse.add(res) + const heartbeat = setInterval(() => { + try { + res.write(": heartbeat\n\n") + } catch { + clearInterval(heartbeat) + } + }, 1000) + req.on("close", () => { + clearInterval(heartbeat) + sse.delete(res) + }) + return + } + + if (method === "GET" && path === "/api/session") { + const list = Array.from(sessions.values()).map((s) => s.data) + const limit = Number(url.searchParams.get("limit") ?? "50") + return data(res, list.slice(0, Number.isFinite(limit) ? limit : 50), { cursor: { previous: null, next: null } }) + } + + if (method === "POST" && path === "/api/session") { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + const parsed = safeParse(body) + const directory = (parsed?.location as { directory?: string } | undefined)?.directory ?? locationDirectory(url) + const id = nextID("ses") + seedSession(id, directory, (parsed?.title as string) ?? "mock v2 session") + emit("session.created", { + sessionID: id, + slug: "mock-slug", + version: V2_VERSION, + projectID: "mock-project", + location: { directory }, + subpath: "", + title: (parsed?.title as string) ?? "mock v2 session", + }, directory) + data(res, sessions.get(id)?.data) + }) + return + } + + if (method === "GET" && path === "/api/permission/request") { + return data(res, Array.from(permissions.values())) + } + + if (method === "GET" && path === "/api/form") { + return data(res, Array.from(forms.values())) + } + + const sessionMatch = path.match(/^\/api\/session\/([^/]+)$/) + if (sessionMatch) { + const session = sessions.get(sessionMatch[1]) + if (!session) return json(res, 404, { _tag: "SessionNotFoundError" }) + if (method === "GET") return data(res, session.data) + if (method === "DELETE") { + sessions.delete(sessionMatch[1]) + res.writeHead(204) + return res.end() + } + if (method === "PATCH") { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + const parsed = safeParse(body) + if (parsed?.title) session.data.title = parsed.title + res.writeHead(204) + res.end() + }) + return + } + } + + const messageListMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) + if (messageListMatch && method === "GET") { + const session = sessions.get(messageListMatch[1]) + if (!session) return json(res, 404, { _tag: "SessionNotFoundError" }) + const cursor = url.searchParams.get("cursor") + // Same rule as the real 2.x server: a cursor already carries its + // direction, so `order` must not be sent with it. + if (cursor && url.searchParams.get("order")) { + return json(res, 400, { _tag: "InvalidCursorError", message: "Cursor cannot be combined with order" }) + } + const order = url.searchParams.get("order") === "asc" ? "asc" : "desc" + // The real server answers an unlimited read with a bounded page, so the + // mock does too — otherwise "read the whole transcript" never pages and + // the cursor path goes untested. + const limit = Number(url.searchParams.get("limit") ?? String(MESSAGE_PAGE_SIZE)) + // The mock stores entries newest-first (like a 2.x session's own history), + // so the chronological view is its reverse. Paging is counted from the + // newest end of that view, which is what a 2.x cursor means. + const ascending = [...session.messages].reverse() + const taken = cursor ? Number(cursor.replace("cursor-", "").split("-")[0]) : 0 + const size = Number.isFinite(limit) && limit > 0 ? limit : ascending.length + const from = Math.max(0, ascending.length - taken - size) + const to = ascending.length - taken + const page = order === "asc" ? ascending.slice(from, to) : ascending.slice(from, to).reverse() + const hasMore = from > 0 + return json(res, 200, { + data: page, + cursor: { + previous: hasMore ? `cursor-${ascending.length - from}-previous` : null, + next: null, + }, + }) + } + + const promptMatch = path.match(/^\/api\/session\/([^/]+)\/prompt$/) + if (promptMatch && method === "POST") { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + const parsed = safeParse(body) + const text = (parsed?.text as string) ?? "" + const userID = nextID("msg") + const directory = locationDirectory(url) + data(res, { id: userID, sessionID: promptMatch[1], time: { created: Date.now() }, type: "user", text, delivery: "steer" }) + scriptAssistantReply(promptMatch[1], directory) + }) + return + } + + const interruptMatch = path.match(/^\/api\/session\/([^/]+)\/interrupt$/) + if (interruptMatch && method === "POST") { + emit("session.execution.interrupted", { sessionID: interruptMatch[1] }) + res.writeHead(204) + return res.end() + } + + const permissionReplyMatch = path.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/) + if (permissionReplyMatch && method === "POST") { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + const parsed = safeParse(body) + const requestID = permissionReplyMatch[2] + const pending = permissions.get(requestID) + permissions.delete(requestID) + if (pending) { + emit("permission.replied", { sessionID: pending.sessionID, requestID, reply: parsed?.decision }) + } + res.writeHead(204) + res.end() + }) + return + } + + const formReplyMatch = path.match(/^\/api\/session\/([^/]+)\/form\/([^/]+)\/reply$/) + if (formReplyMatch && method === "POST") { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + const parsed = safeParse(body) + const formID = formReplyMatch[2] + const form = forms.get(formID) + forms.delete(formID) + if (form) emit("form.replied", { id: formID, sessionID: form.sessionID, answer: parsed?.answer }) + res.writeHead(204) + res.end() + }) + return + } + + const formDeleteMatch = path.match(/^\/api\/session\/([^/]+)\/form\/([^/]+)$/) + if (formDeleteMatch && method === "DELETE") { + const form = forms.get(formDeleteMatch[2]) + forms.delete(formDeleteMatch[2]) + if (form) emit("form.cancelled", { id: form.id, sessionID: form.sessionID }) + res.writeHead(204) + return res.end() + } + + const stageMatch = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/) + if (stageMatch && method === "POST") { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + const parsed = safeParse(body) + const session = sessions.get(stageMatch[1]) + if (session) session.data.revert = { messageID: parsed?.messageID, files: [] } + json(res, 200, { location: { directory: locationDirectory(url) }, data: { messageID: parsed?.messageID, files: [] } }) + }) + return + } + + const revertMatch = path.match(/^\/api\/session\/([^/]+)\/revert$/) + if (revertMatch && method === "DELETE") { + const session = sessions.get(revertMatch[1]) + if (session) delete session.data.revert + res.writeHead(204) + return res.end() + } + + const diffMatch = path.match(/^\/api\/session\/([^/]+)\/diff$/) + if (diffMatch && method === "GET") { + return data(res, [{ file: "src/mock.ts", patch: "@@ -1 +1 @@\n-old\n+new\n", additions: 1, deletions: 1, status: "modified" }]) + } + + if (method === "GET" && path === "/api/project") { + return data(res, [{ id: "mock-project", canonical: V2_DIRECTORY, vcs: "git", time: { created: Date.now(), updated: Date.now() } }]) + } + + if (method === "GET" && path === "/api/location") { + const directory = locationDirectory(url) + return json(res, 200, { directory, project: { id: "mock-project", directory, canonical: directory } }) + } + + if (method === "GET" && path === "/api/vcs") { + return data(res, { provider: "git", branch: { current: "mock-branch", default: "main" } }) + } + + if (method === "GET" && path === "/api/vcs/diff") { + return data(res, [{ file: "README.md", patch: "@@ -1 +1 @@\n-a\n+b\n", additions: 1, deletions: 1, status: "modified" }]) + } + + if (method === "GET" && path === "/api/fs/list") { + return data(res, [ + { path: "src", type: "directory" }, + { path: "README.md", type: "file" }, + ]) + } + + if (method === "GET" && path === "/api/agent") { + return data(res, [{ id: "build", name: "Build", description: "mock build agent", mode: "primary", hidden: false }]) + } + + if (method === "GET" && path === "/api/command") { + return data(res, [{ name: "review", description: "review changes" }]) + } + + if (method === "GET" && path === "/api/provider") { + return data(res, [{ id: "mock", name: "Mock Provider", activation: "enabled" }]) + } + + if (method === "GET" && path === "/api/model") { + return data(res, [ + { + id: "mock-model", + modelID: "mock-model", + providerID: "mock", + name: "Mock Model", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ], + limit: { context: 200000, output: 8192 }, + }, + ]) + } + + if (method === "GET" && path === "/api/model/default") { + return data(res, { id: "mock-model", modelID: "mock-model", providerID: "mock", name: "Mock Model" }) + } + + // forge's own routes are shared by both majors (issue #15). + if (method === "GET" && path === "/__forge/pr") { + return json(res, 200, { directory: locationDirectory(url), branch: "forge/mock", pr: { number: 1, state: "open" }, checked_at: 1 }) + } + + if (path.startsWith("/api/")) return json(res, 404, { _tag: "NotFoundError", message: `no v2 handler for ${method} ${path}` }) + + return html(res, 200) + }) + + /** + * Prepend `pairs` older user/assistant exchanges to a session's transcript so + * a full read needs several cursor pages. Returns the oldest user message id. + */ + function seedHistory(sessionID: string, pairs: number): string | undefined { + const session = sessions.get(sessionID) + if (!session) return undefined + const block: V2MessageEntry[] = [] + let firstUserID: string | undefined + for (let i = 0; i < pairs; i++) { + const userID = nextID("msg") + const assistantID = nextID("msg") + if (i === 0) firstUserID = userID + block.push( + { id: userID, type: "user", text: `older question ${i}`, time: { created: Date.now() - (pairs - i) * 1000 } }, + { + id: assistantID, + type: "assistant", + finish: "stop", + content: [{ type: "text", text: `older answer ${i}` }], + time: { created: Date.now() - (pairs - i) * 1000, completed: Date.now() - (pairs - i) * 900 }, + } as unknown as V2MessageEntry, + ) + } + // Storage is newest-first, and this block is older than everything already + // there, so it goes on the tail in newest-first order. + session.messages.push(...block.reverse()) + return firstUserID + } + + return { + /** SSE listeners currently attached — lets a test wait until the stream is live. */ + connected: () => sse.size, + askPermission, + askForm, + seedHistory, + pendingPermissions: () => Array.from(permissions.keys()), + pendingForms: () => Array.from(forms.keys()), + sessionDirectory: () => V2_DIRECTORY, + } +} + +function safeParse(text: string): Record | null { + try { + return JSON.parse(text || "{}") + } catch { + return null + } +} diff --git a/tests/fixtures/mock-opencode-server.ts b/tests/fixtures/mock-opencode-server.ts index 25061fb1d..c52bd080c 100644 --- a/tests/fixtures/mock-opencode-server.ts +++ b/tests/fixtures/mock-opencode-server.ts @@ -73,6 +73,7 @@ import http from "node:http" import { randomUUID } from "node:crypto" +import { installV2Api } from "./mock-opencode-server-v2.ts" export interface MockServerOptions { port: number @@ -113,6 +114,14 @@ export interface MockServerOptions { forgePr?: Record /** /__forge/pr answers 503 + X-Forge-State: asleep (the endpoint never wakes it). */ forgeAsleep?: boolean + /** + * Serve the opencode **2.x** API instead of 1.x (`--v2`). Responses use the + * `{"data": …}` envelope, `?location[directory]=` scoping, newest-first + * cursor-paginated message lists and the granular `session.*` event stream, + * mirroring the captures in docs/opencode-v2-port/. Used by the adapter + * tests to exercise src/lib/sdk-v2.ts without a live 2.x server. + */ + apiVersion?: "v1" | "v2" } interface StoredSession { @@ -240,6 +249,7 @@ export function createMockOpencodeServer(opts: MockServerOptions) { seedDiff = false, forgePr, forgeAsleep = false, + apiVersion = "v1", } = opts const sessions = new Map() @@ -441,6 +451,10 @@ export function createMockOpencodeServer(opts: MockServerOptions) { console.log(`[mock-opencode-server] ${method} ${path} -> ${res.statusCode}`) }) + // In --v2 mode the 2.x listener owns this request, including its 401 — the + // 1.x handlers below must not answer as well. + if (apiVersion === "v2") return + if (failAuth) { unauthorized(res) return @@ -677,8 +691,14 @@ export function createMockOpencodeServer(opts: MockServerOptions) { json(res, 404, { error: `mock-opencode-server: no handler for ${method} ${path}` }) }) + // --v2 installs the 2.x API surface on top of the same http.Server; the 1.x + // handlers above stay registered but are never reached. + const v2 = apiVersion === "v2" ? installV2Api(server, { port, failAuth, replyText, replyDelayMs }) : null + return { server, + /** Scripted 2.x interactions (permission/form asks) — null in 1.x mode. */ + v2, url: `http://localhost:${port}`, listen(): Promise { return new Promise((resolve) => server.listen(port, "0.0.0.0", () => resolve())) @@ -699,9 +719,10 @@ export function createMockOpencodeServer(opts: MockServerOptions) { } } -function parseArgs(argv: string[]): MockServerOptions & { failAuth: boolean; seedSessions: boolean; seedDiff: boolean } { - const opts: MockServerOptions & { failAuth: boolean; seedSessions: boolean; seedDiff: boolean } = { +function parseArgs(argv: string[]): MockServerOptions & { failAuth: boolean; seedSessions: boolean; seedDiff: boolean; apiVersion: "v1" | "v2" } { + const opts: MockServerOptions & { failAuth: boolean; seedSessions: boolean; seedDiff: boolean; apiVersion: "v1" | "v2" } = { port: 4096, + apiVersion: "v1", failAuth: false, seedSessions: false, seedDiff: false, @@ -712,6 +733,7 @@ function parseArgs(argv: string[]): MockServerOptions & { failAuth: boolean; see else if (argv[i] === "--seed-sessions") opts.seedSessions = true else if (argv[i] === "--seed-diff") opts.seedDiff = true else if (argv[i] === "--forge-pr") opts.forgePr = { [DEFAULT_DIRECTORY]: FORGE_PR } + else if (argv[i] === "--v2") opts.apiVersion = "v2" } return opts }