diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 9d4b23d..8dedcfb 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -14,6 +14,7 @@
"packages/plugin/README.md",
"packages/plugin/docs/protocol/schemas",
"assets",
- ".opencode"
+ ".opencode",
+ "packages/plugin/docs/protocol/openapi.json"
]
}
diff --git a/README.md b/README.md
index b8bc362..f010007 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@
+
@@ -54,6 +55,7 @@ and permission requests while it runs, and every Unit is journaled so a stopped
Typed results. Pass a zod schema (or a JSON Schema) and get a validated value back. When a model answers in prose, the engine repairs or extracts the value.
Any model per Unit. model: "provider/model#variant" puts different models in one Run, for example an independent verifier.
Watch and steer. A TUI panel and a web app show phases, Units and transcripts. Stop a Run, restart a Unit, answer a question.
+ Build on it. A versioned protocol with an OpenAPI 3.1 spec: any app can list, start, follow and answer Workflows, no TUI needed.
Never hangs headless. Questions carry a fallback answer, and permission requests are denied with a message when nobody is watching.
Resumable. Every Run is journaled. Resume replays the finished Units and runs the rest live.
Bounded. 5 Units per Run and 5 Runs at once by default, per-provider caps, token budgets and hard limits.
@@ -117,20 +119,26 @@ it writes a Workflow for the task at hand and runs it with `workflow_inline` onc
## Install
-Add the package to `plugins` in `opencode.json` (one project) or in your global OpenCode config:
+```sh
+opencode plugin add @malhashemi/opencode-dynamic-workflows
+opencode service restart
+```
+
+That adds the package to your global OpenCode config. The TUI part loads with the server part; there is nothing else
+to install. `opencode plugin update` brings later releases.
+
+Or add it to `plugins` yourself, in a project's `opencode.json` or the global one. The object form takes
+[options](#configuration); a project entry for the same package overrides the global one's options:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
- "plugins": ["@malhashemi/opencode-dynamic-workflows"]
+ "plugins": [{ "package": "@malhashemi/opencode-dynamic-workflows", "options": { "inline": "ask" } }]
}
```
-Restart OpenCode. The TUI part loads with the server part; there is nothing else to install.
-
-To run from a checkout of this repository, run `bun install && bun run build` and put the absolute path of
-`packages/plugin` in `plugins` instead. A git spec (`git+https://…#[`) works for a repository whose root is the
-packed package (`bun run pack` output).
+To run from a checkout of this repository, see the [development guide](docs/development.md): a checkout and the
+installed package are two plugins to OpenCode, so do not load both in one place.
## Usage
@@ -160,21 +168,39 @@ preview arrives in the session that started it (plugin option `notify`).
`/workflows` (or leader f) opens the Run library. From there you open a Run (phases, Units,
activity, result) and a Unit (prompt, output, transcript). The session you are in also shows its Runs in a strip above
-the prompt, in the sidebar and in a run panel (`/workflows panel`).
+the prompt, in the sidebar and in a run panel (`/workflows panel`). The header links to the same page in the web app.
+
+]
+ 
+ A Run in the TUI: phases, Units, budget and the typed result.
+
+
+
+
+  |
+  |
+
+
+ | The library: live Runs first, each with its progress. |
+ A Unit: its prompt, and its output as markdown. |
+
+
| Where | Keys |
| --- | --- |
-| Library | ↵ open · a answer · f filter · d clean up finished · p pair a browser · r refresh |
-| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · p parent session |
+| Library | ↵ open · a answer · f filter · d clean up finished · b open in the browser · p pair a device · r refresh |
+| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · b open in the browser · p parent session |
| Run, when finished | e resume · w save as a durable Workflow · d delete its Unit sessions |
+| Approval | ↑/↓, PgUp/PgDn, Home/End scroll the script · ←/→ choose · ↵ confirm |
| Anywhere | j/k or arrows to move · ⌫ back |
`/workflows` also takes `panel`, `answer`, `cleanup`, `pair`, `refresh`, or a Run id or Workflow name to open.
### In the browser
-Every tool result links to its Run in the web app, served by the plugin's Gateway at `http://127.0.0.1:4320` (the next
-free port if that one is taken). A browser on the same machine pairs itself for control actions. For a browser on
+The web app is served by the plugin's Gateway at `http://127.0.0.1:4320` (the next free port if that one is taken).
+The `/workflows` header shows its address and b opens the page you are on; every tool result also links to
+its Run. A browser on the same machine pairs itself for control actions. For a browser on
another device (with `gateway.bind` set to `lan` or `tailscale`), run `/workflows pair` in the TUI and enter the
one-use code.
@@ -322,8 +348,15 @@ flowchart LR
Inline Workflows are model-written code that runs with your privileges, and they are not sandboxed: the approval is
the control. By default a person approves each one (**Run once**, **Always for this project** or **Reject**) after
reading the whole source, with its size and SHA-256, in the TUI (`/workflows`, a scrolling view) or the web app. The
-request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the project was
-approved before.
+script is not loaded, so none of its code runs, until then. Saving inline source as a durable Workflow asks the same
+way. The request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the
+project was approved before. The plugin option `inline` changes this: `"allow"` runs inline Workflows without asking,
+`"deny"` never runs them.
+
+
+ 
+ Approving an inline Workflow: the whole script, highlighted, before any of it runs.
+
Units get OpenCode's permission rules plus the engine's own: `workflow_result` and `question` allowed, `workflow` and
`workflow_inline` denied (no recursion). A Unit's permission request goes to a person when one is attached; headless,
@@ -333,19 +366,28 @@ The Gateway binds to loopback by default, checks `Host` and `Origin` headers, an
remote browsers pair with a one-use code. Read [`docs/security.md`](packages/plugin/docs/security.md) for the trust
model, capabilities, limits and data on disk, and [SECURITY.md](SECURITY.md) to report a vulnerability.
-## Protocol
+## Build on it
-Runs, Units, interactions, events and errors form a versioned protocol (v1) with two transports: OpenCode plugin RPC
-and the Gateway (HTTP + Server-Sent Events). The zod definitions are the source of truth, and JSON Schemas are
-published next to the [protocol reference](packages/plugin/docs/protocol/README.md). TypeScript clients can import the
-types from `@malhashemi/opencode-dynamic-workflows/protocol` and the RPC contract from
-`@malhashemi/opencode-dynamic-workflows/rpc`.
+Your app can run Workflows too: an ADE, an editor extension, a dashboard, a bot. Everything the TUI and the web app do
+goes through one versioned protocol (v1, which changes only by addition), with two transports:
+
+- **OpenCode plugin RPC** on the OpenCode server (`POST /api/rpc/workflow/`), for apps that already talk to
+ OpenCode. It uses your existing OpenCode credentials; the TypeScript contract is
+ `@malhashemi/opencode-dynamic-workflows/rpc`, the types `@malhashemi/opencode-dynamic-workflows/protocol`.
+- **The Gateway** (HTTP + Server-Sent Events), described by an [OpenAPI 3.1 document](packages/plugin/docs/protocol/openapi.json)
+ that a running Gateway also serves at `/v1/openapi.json`.
```sh
-curl -s http://127.0.0.1:4320/v1/runs | jq '.runs[0]'
+curl -s http://127.0.0.1:4320/v1/openapi.json | jq '.paths | keys'
curl -N "http://127.0.0.1:4320/v1/events?location=/my/project"
```
+The [integration guide](docs/integrating.md) covers both, including how to be "attached" so that questions and
+approvals reach your users. The [protocol reference](packages/plugin/docs/protocol/README.md) and
+[JSON Schemas](packages/plugin/docs/protocol/schemas) have the details. If you add support to your app, tell us in an
+[issue](https://github.com/malhashemi/opencode-dynamic-workflows/issues): we will list it here and help with what the
+protocol is missing.
+
## Troubleshooting
| Symptom | Fix |
diff --git a/assets/tui-approval.webp b/assets/tui-approval.webp
new file mode 100644
index 0000000..e5c161d
Binary files /dev/null and b/assets/tui-approval.webp differ
diff --git a/assets/tui-library.webp b/assets/tui-library.webp
new file mode 100644
index 0000000..a43a8df
Binary files /dev/null and b/assets/tui-library.webp differ
diff --git a/assets/tui-run.webp b/assets/tui-run.webp
new file mode 100644
index 0000000..445e577
Binary files /dev/null and b/assets/tui-run.webp differ
diff --git a/assets/tui-unit.webp b/assets/tui-unit.webp
new file mode 100644
index 0000000..6385572
Binary files /dev/null and b/assets/tui-unit.webp differ
diff --git a/docs/development.md b/docs/development.md
index a6ccffe..978ed73 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -53,6 +53,13 @@ entries by path):
`WF_GATEWAY` (default `http://127.0.0.1:4320`).
- **Worktree Units** (`isolation: "worktree"`) only work when the plugin is configured in a committed
`opencode.json` (or globally): worktrees check out the committed tree.
+- **Do not load the checkout where the published package is installed.** A checkout (plugin id = its path) and the
+ npm package (id `@malhashemi/opencode-dynamic-workflows`) are two plugins to OpenCode: both load, both register
+ the same tools and RPC methods, and either may answer, with its own options (a global `inline: "allow"` would
+ bypass a project's approval). Test the checkout on a server with its own config directory, as the live tests do:
+ `XDG_CONFIG_HOME=/tmp/wf-dev-config opencode serve …` with that directory's `opencode/opencode.json` holding your
+ providers and no plugins. To change the published package's options for one project, name the same package in
+ the project config: its options override the global ones.
## Checks
diff --git a/docs/integrating.md b/docs/integrating.md
new file mode 100644
index 0000000..478ecca
--- /dev/null
+++ b/docs/integrating.md
@@ -0,0 +1,210 @@
+# Integrating dynamic workflows into your app
+
+This guide is for people who build on OpenCode: an ADE, an editor extension, a dashboard, a bot. Everything
+the TUI and the web app do is available to you through a versioned protocol, and you do not need the TUI. A
+Workflow runs in the OpenCode server, so a user who installs the plugin gets Workflows in your app as well.
+
+If you add support, [open an issue](https://github.com/malhashemi/opencode-dynamic-workflows/issues) or a pull
+request that adds your app to the README. We are glad to help, and to add what the protocol is missing.
+
+## What you get
+
+- **Workflows:** list the saved Workflows of a project, with each one's `args` JSON Schema, so you can build a
+ form for it.
+- **Runs:** start a saved Workflow or inline source; follow its phases, Units, tokens and cost live; stop,
+ resume, restart a Unit; read the result and every Unit's transcript.
+- **People in the loop:** show a Workflow's questions, a Unit's permission requests and inline-Workflow
+ approvals, and send the answers.
+- **History:** every Run is journaled, including Runs from before a restart.
+
+## Two ways in
+
+| | OpenCode plugin RPC | The Gateway (HTTP + SSE) |
+| -------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| Address | the OpenCode server: `POST /api/rpc/workflow/` | `http://127.0.0.1:4320/v1/…` (next free port if taken) |
+| Auth | whatever your app already uses for OpenCode | a device token for control actions ([pairing](#tokens)) |
+| Events | the OpenCode event stream, as `rpc.workflow.event` | Server-Sent Events at `GET /v1/events` |
+| Contract | [`@malhashemi/opencode-dynamic-workflows/rpc`](../packages/plugin/src/service/rpc.ts) (typed) | [OpenAPI 3.1](../packages/plugin/docs/protocol/openapi.json), also served at `GET /v1/openapi.json` |
+| Best for | apps that already talk to an OpenCode server | scripts, other machines, apps that do not use the OpenCode client |
+
+Both carry the same resources, commands and events ([protocol reference](../packages/plugin/docs/protocol/README.md),
+[JSON Schemas](../packages/plugin/docs/protocol/schemas)). Protocol v1 changes only by addition: new optional
+fields, event types and error codes. Ignore what you do not know, and check `protocol` in `info`.
+
+Detect the plugin with `info`: it answers with the protocol version, the plugin and OpenCode versions, the
+capabilities (`runs`, `interactions`, `inline-approval`, …), the limits and the Gateway's address.
+
+## Be "attached", or nobody can answer
+
+A Run asks a person three kinds of thing: a Workflow's `ask()` questions, a Unit's permission requests, and the
+approval of inline (model-written) Workflows. They wait only while some client is **attached** to the project.
+With nobody attached, a question takes its fallback answer, a permission request is denied, and an inline
+Workflow is refused, so a Run never hangs.
+
+- **Plugin RPC:** call `attach({ surface, ttlMs: 45000, sessionID })` with an id unique to this window of your app
+ (for example `my-app-`), and repeat it every 20 seconds
+ or so while your app shows Workflows. `sessionID` is the session in view, if any. Call `detach` on exit.
+- **Gateway:** an open `GET /v1/events` stream counts as attached.
+
+Show pending interactions from `run.interactions` (and the `interaction.pending` / `interaction.resolved`
+events), and answer with `replyInteraction`: one list of chosen labels per question. For an inline approval,
+`interaction.approval` carries the whole `source`, its `sha256` and `bytes`, the `action` (`run` or `save`) and,
+for a save, the `target` file. Show the source to the person before they choose; that approval is the only thing
+between model-written code and their machine. The choices are `Run once`, `Always for this project` and `Reject`
+(`Save` and `Reject` for a save).
+
+## A Run from start to finish (plugin RPC)
+
+```ts
+import { OpenCode } from "@opencode/client"
+import type { PendingInteraction, ProtocolEvent } from "@malhashemi/opencode-dynamic-workflows/protocol"
+import { WorkflowRpc } from "@malhashemi/opencode-dynamic-workflows/rpc"
+
+const directory = "/path/to/project"
+const client = OpenCode.make({ baseUrl: "http://127.0.0.1:4096", headers: { "x-opencode-directory": directory } })
+const workflow = client.rpc(WorkflowRpc)
+const at = { location: { directory } }
+
+// Your UI: show the interaction (its questions and options; for an approval, the whole `approval.source`) and
+// resolve with the labels the person chose, one list per question.
+declare function askPerson(interaction: PendingInteraction): Promise
+
+// Be attached while your UI is open, so questions and approvals wait for the person. One surface id per window:
+// two windows sharing an id would replace each other's attachment, and one's detach would end both.
+const surface = `my-app-${crypto.randomUUID()}`
+await workflow.attach({ surface, ttlMs: 45_000 }, at)
+// The heartbeat also re-reads the Run, so a dropped event (a question, the Run's end) is caught within 20 s.
+// For exact recovery, track each event's `seq` and `epoch` and use `eventsSince` (see "Events worth handling").
+const heartbeat = setInterval(() => {
+ void workflow.attach({ surface, ttlMs: 45_000 }, at)
+ void reconcile()
+}, 20_000)
+
+let runId: string | null = null
+const early: ProtocolEvent[] = [] // events that arrive before startRun returns the Run's id
+// An event and a re-read can both see one interaction: ask about each once at a time. Something that failed is
+// left undone, so the next re-read tries again.
+const asking = new Set()
+const answered = new Set()
+let finishing = false
+let finished = false
+
+async function answer(interaction: PendingInteraction) {
+ const id = interaction.interactionId
+ // A native OpenCode form (`interaction.form`) is answered through OpenCode's own form API, not replyInteraction.
+ if (!runId || interaction.form || answered.has(id) || asking.has(id)) return
+ asking.add(id)
+ try {
+ const answers = await askPerson(interaction)
+ await workflow.replyInteraction({ runId, interactionId: id, answers }, at)
+ answered.add(id)
+ } finally {
+ asking.delete(id)
+ }
+}
+
+async function finish() {
+ if (!runId || finished || finishing) return
+ finishing = true
+ try {
+ const { status, result } = await workflow.getResult({ runId }, at)
+ console.log(status, result)
+ clearInterval(heartbeat)
+ await workflow.detach({ surface }, at)
+ finished = true
+ } finally {
+ finishing = false
+ }
+}
+
+// Answer what is pending and finish a Run that has ended, from the Run itself rather than from events.
+async function reconcile() {
+ if (!runId || finished) return
+ const { run } = await workflow.getRun({ runId }, at)
+ for (const interaction of run.interactions) await answer(interaction)
+ if (run.status !== "queued" && run.status !== "running") await finish()
+}
+
+async function handle(event: ProtocolEvent) {
+ if (event.runId !== runId) return
+ if (event.type === "interaction.pending") await answer(event.data as PendingInteraction)
+ if (event.type === "run.ended") await finish()
+}
+
+// Listen before starting, and hold what arrives before the Run's id is known.
+workflow.events.on("event", ({ data }) => void (runId ? handle(data) : early.push(data)))
+
+const { workflows } = await workflow.listWorkflows({}, at) // each with its args JSON Schema
+runId = (await workflow.startRun({ name: workflows[0]!.key, args: {} }, at)).runId
+for (const event of early.splice(0)) await handle(event)
+// Anything that became pending, or a Run that ended, before we were listening for this id.
+await reconcile()
+```
+
+This is a script: if a call throws, the process ends and the heartbeat with it. In a long-lived app, stop the
+heartbeat and `detach` when the view closes, whatever happened, or questions keep waiting for a view that is gone.
+
+A pending interaction with a `form` field is a native OpenCode form (a Unit's `question` call the engine could not
+take over). Answer it with OpenCode's form API, `client.session.form.reply({ sessionID, formID, answer })` with
+the interaction's `sessionID` and `form.formID`, not with `replyInteraction`.
+
+Without the typed client, a plugin RPC call is a plain POST to the OpenCode server: the input goes in `input`,
+the answer comes back in `output`, and the project is the `x-opencode-directory` header (or `?location=`):
+
+```sh
+curl -s -u "opencode:$OPENCODE_SERVER_PASSWORD" -H "x-opencode-directory: $P" -H "content-type: application/json" \
+ -X POST -d '{"input":{}}' http://127.0.0.1:4096/api/rpc/workflow/listWorkflows # → {"output":{"workflows":[…]}}
+```
+
+## The same over the Gateway
+
+```sh
+GW=http://127.0.0.1:4320
+P=/path/to/project
+
+curl -s "$GW/v1/info" # versions, capabilities, locations
+curl -s "$GW/v1/workflows?location=$P" | jq '.workflows[].key'
+curl -N "$GW/v1/events?location=$P" # keep open: live events, and you are attached
+
+curl -s -X POST "$GW/v1/runs?location=$P" \
+ -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
+ -d '{"name":"review-diff","args":{"base":"main"}}' # → { "runId": "…" }
+
+curl -s -X POST "$GW/v1/runs/$RUN/interactions/$ID/reply" \
+ -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
+ -d '{"answers":[["Run once"]]}'
+
+curl -s "$GW/v1/runs/$RUN/result"
+```
+
+Every route, parameter and response is in the [OpenAPI document](../packages/plugin/docs/protocol/openapi.json);
+point your client generator at it, or at `GET /v1/openapi.json` on a running Gateway.
+
+## Tokens
+
+Reads need a token with `read` scope, except from the same machine to a Gateway bound to loopback (the
+default). Control actions (start, stop, resume, answer, save, cleanup) need
+`Authorization: Bearer ` with `control` scope:
+
+- A browser page served by the Gateway itself pairs automatically (`POST /v1/pair/local`).
+- Anything else gets a one-use code from the TUI (`/workflows pair`, valid five minutes) and exchanges it at
+ `POST /v1/pair` with `{ "code": "…", "name": "my-app" }`. Store the token; revoke it by deleting its entry in
+ `$XDG_STATE_HOME/opencode-dynamic-workflows/gateway-tokens.json` (the running Gateway notices at once).
+- The plugin RPC route needs no Gateway token: it uses your OpenCode credentials.
+
+The [security guide](../packages/plugin/docs/security.md) covers the Gateway's host, origin and CORS rules, and
+binding it beyond loopback.
+
+## Events worth handling
+
+| Event | What to do |
+| --------------------------------------------- | ----------------------------------------------------------------------------- |
+| `run.started`, `run.updated`, `run.ended` | Update the Run's row: status, phase, usage, result preview. |
+| `unit.updated` | Update one Unit. `output` may be elided; fetch the Unit when you show it. |
+| `interaction.pending`, `interaction.resolved` | Show or clear a question, permission request or approval. |
+| `library.changed` | A Run was added to or removed from history: refresh your list of Runs. |
+| `activity.appended` | The Run's log line, phase change or capability use. |
+| `resync.required` | You missed events (a restart, or you fell behind): re-read the Runs you show. |
+
+Events carry a `seq` and an `epoch`. Over SSE, reconnect with `Last-Event-ID` to receive what you missed; over
+RPC, `eventsSince({ after, epoch })` does the same.
diff --git a/packages/plugin/README.md b/packages/plugin/README.md
index 5f39761..13dd214 100644
--- a/packages/plugin/README.md
+++ b/packages/plugin/README.md
@@ -10,6 +10,7 @@
+
@@ -56,6 +57,7 @@ and permission requests while it runs, and every Unit is journaled so a stopped
Typed results. Pass a zod schema (or a JSON Schema) and get a validated value back. When a model answers in prose, the engine repairs or extracts the value.
Any model per Unit. model: "provider/model#variant" puts different models in one Run, for example an independent verifier.
Watch and steer. A TUI panel and a web app show phases, Units and transcripts. Stop a Run, restart a Unit, answer a question.
+ Build on it. A versioned protocol with an OpenAPI 3.1 spec: any app can list, start, follow and answer Workflows, no TUI needed.
Never hangs headless. Questions carry a fallback answer, and permission requests are denied with a message when nobody is watching.
Resumable. Every Run is journaled. Resume replays the finished Units and runs the rest live.
Bounded. 5 Units per Run and 5 Runs at once by default, per-provider caps, token budgets and hard limits.
@@ -119,20 +121,26 @@ it writes a Workflow for the task at hand and runs it with `workflow_inline` onc
## Install
-Add the package to `plugins` in `opencode.json` (one project) or in your global OpenCode config:
+```sh
+opencode plugin add @malhashemi/opencode-dynamic-workflows
+opencode service restart
+```
+
+That adds the package to your global OpenCode config. The TUI part loads with the server part; there is nothing else
+to install. `opencode plugin update` brings later releases.
+
+Or add it to `plugins` yourself, in a project's `opencode.json` or the global one. The object form takes
+[options](#configuration); a project entry for the same package overrides the global one's options:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
- "plugins": ["@malhashemi/opencode-dynamic-workflows"]
+ "plugins": [{ "package": "@malhashemi/opencode-dynamic-workflows", "options": { "inline": "ask" } }]
}
```
-Restart OpenCode. The TUI part loads with the server part; there is nothing else to install.
-
-To run from a checkout of this repository, run `bun install && bun run build` and put the absolute path of
-`packages/plugin` in `plugins` instead. A git spec (`git+https://…#[`) works for a repository whose root is the
-packed package (`bun run pack` output).
+To run from a checkout of this repository, see the [development guide](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/development.md): a checkout and the
+installed package are two plugins to OpenCode, so do not load both in one place.
## Usage
@@ -162,21 +170,39 @@ preview arrives in the session that started it (plugin option `notify`).
`/workflows` (or leader f) opens the Run library. From there you open a Run (phases, Units,
activity, result) and a Unit (prompt, output, transcript). The session you are in also shows its Runs in a strip above
-the prompt, in the sidebar and in a run panel (`/workflows panel`).
+the prompt, in the sidebar and in a run panel (`/workflows panel`). The header links to the same page in the web app.
+
+]
+ 
+ A Run in the TUI: phases, Units, budget and the typed result.
+
+
+
+
+  |
+  |
+
+
+ | The library: live Runs first, each with its progress. |
+ A Unit: its prompt, and its output as markdown. |
+
+
| Where | Keys |
| --- | --- |
-| Library | ↵ open · a answer · f filter · d clean up finished · p pair a browser · r refresh |
-| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · p parent session |
+| Library | ↵ open · a answer · f filter · d clean up finished · b open in the browser · p pair a device · r refresh |
+| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · b open in the browser · p parent session |
| Run, when finished | e resume · w save as a durable Workflow · d delete its Unit sessions |
+| Approval | ↑/↓, PgUp/PgDn, Home/End scroll the script · ←/→ choose · ↵ confirm |
| Anywhere | j/k or arrows to move · ⌫ back |
`/workflows` also takes `panel`, `answer`, `cleanup`, `pair`, `refresh`, or a Run id or Workflow name to open.
### In the browser
-Every tool result links to its Run in the web app, served by the plugin's Gateway at `http://127.0.0.1:4320` (the next
-free port if that one is taken). A browser on the same machine pairs itself for control actions. For a browser on
+The web app is served by the plugin's Gateway at `http://127.0.0.1:4320` (the next free port if that one is taken).
+The `/workflows` header shows its address and b opens the page you are on; every tool result also links to
+its Run. A browser on the same machine pairs itself for control actions. For a browser on
another device (with `gateway.bind` set to `lan` or `tailscale`), run `/workflows pair` in the TUI and enter the
one-use code.
@@ -315,8 +341,15 @@ from the first changed prompt on everything runs live. Units that failed run aga
Inline Workflows are model-written code that runs with your privileges, and they are not sandboxed: the approval is
the control. By default a person approves each one (**Run once**, **Always for this project** or **Reject**) after
reading the whole source, with its size and SHA-256, in the TUI (`/workflows`, a scrolling view) or the web app. The
-request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the project was
-approved before.
+script is not loaded, so none of its code runs, until then. Saving inline source as a durable Workflow asks the same
+way. The request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the
+project was approved before. The plugin option `inline` changes this: `"allow"` runs inline Workflows without asking,
+`"deny"` never runs them.
+
+
+ 
+ Approving an inline Workflow: the whole script, highlighted, before any of it runs.
+
Units get OpenCode's permission rules plus the engine's own: `workflow_result` and `question` allowed, `workflow` and
`workflow_inline` denied (no recursion). A Unit's permission request goes to a person when one is attached; headless,
@@ -326,19 +359,28 @@ The Gateway binds to loopback by default, checks `Host` and `Origin` headers, an
remote browsers pair with a one-use code. Read [`docs/security.md`](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/security.md) for the trust
model, capabilities, limits and data on disk, and [SECURITY.md](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/SECURITY.md) to report a vulnerability.
-## Protocol
+## Build on it
-Runs, Units, interactions, events and errors form a versioned protocol (v1) with two transports: OpenCode plugin RPC
-and the Gateway (HTTP + Server-Sent Events). The zod definitions are the source of truth, and JSON Schemas are
-published next to the [protocol reference](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/protocol/README.md). TypeScript clients can import the
-types from `@malhashemi/opencode-dynamic-workflows/protocol` and the RPC contract from
-`@malhashemi/opencode-dynamic-workflows/rpc`.
+Your app can run Workflows too: an ADE, an editor extension, a dashboard, a bot. Everything the TUI and the web app do
+goes through one versioned protocol (v1, which changes only by addition), with two transports:
+
+- **OpenCode plugin RPC** on the OpenCode server (`POST /api/rpc/workflow/`), for apps that already talk to
+ OpenCode. It uses your existing OpenCode credentials; the TypeScript contract is
+ `@malhashemi/opencode-dynamic-workflows/rpc`, the types `@malhashemi/opencode-dynamic-workflows/protocol`.
+- **The Gateway** (HTTP + Server-Sent Events), described by an [OpenAPI 3.1 document](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/protocol/openapi.json)
+ that a running Gateway also serves at `/v1/openapi.json`.
```sh
-curl -s http://127.0.0.1:4320/v1/runs | jq '.runs[0]'
+curl -s http://127.0.0.1:4320/v1/openapi.json | jq '.paths | keys'
curl -N "http://127.0.0.1:4320/v1/events?location=/my/project"
```
+The [integration guide](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/integrating.md) covers both, including how to be "attached" so that questions and
+approvals reach your users. The [protocol reference](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/protocol/README.md) and
+[JSON Schemas](https://github.com/malhashemi/opencode-dynamic-workflows/tree/main/packages/plugin/docs/protocol/schemas) have the details. If you add support to your app, tell us in an
+[issue](https://github.com/malhashemi/opencode-dynamic-workflows/issues): we will list it here and help with what the
+protocol is missing.
+
## Troubleshooting
| Symptom | Fix |
diff --git a/packages/plugin/docs/protocol/README.md b/packages/plugin/docs/protocol/README.md
index 169315e..e82a47a 100644
--- a/packages/plugin/docs/protocol/README.md
+++ b/packages/plugin/docs/protocol/README.md
@@ -9,7 +9,9 @@ and third-party tools. It has two transports that carry the same resources, comm
| Gateway (HTTP + SSE) | the web app, scripts, other machines | `http://127.0.0.1:4320/v1/…` by default (see [security](../security.md)) |
The zod definitions in `src/protocol/index.ts` are the source of truth. The JSON Schemas in
-[`schemas/`](./schemas) are generated from them (`bun run script/protocol-schemas.ts`). TypeScript clients can
+[`schemas/`](./schemas) and the Gateway's [OpenAPI 3.1 document](./openapi.json) (also served at
+`GET /v1/openapi.json`) are generated from them (`bun run script/protocol-schemas.ts`). For a walkthrough, see the
+[integration guide](../../../../docs/integrating.md). TypeScript clients can
import the types from `@malhashemi/opencode-dynamic-workflows/protocol` and the RPC contract from
`@malhashemi/opencode-dynamic-workflows/rpc`.
@@ -61,6 +63,7 @@ script question back to its fallback, dismisses a Unit's question, and rejects a
| RPC method | Gateway route | Result |
| --- | --- | --- |
+| (Gateway only) | `GET /v1/openapi.json` | this API as an OpenAPI 3.1 document |
| `info` | `GET /v1/info` | versions, capabilities, limits, Gateway URL (Gateway: every location) |
| `listRuns` | `GET /v1/runs?status=&search=&parentSessionID=&since=&limit=&location=` | `{ runs: LibraryEntry[] }` |
| `getRun` | `GET /v1/runs/:runId` | `{ run, live }` |
diff --git a/packages/plugin/docs/protocol/openapi.json b/packages/plugin/docs/protocol/openapi.json
new file mode 100644
index 0000000..9d07872
--- /dev/null
+++ b/packages/plugin/docs/protocol/openapi.json
@@ -0,0 +1,3710 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "OpenCode dynamic workflows: Gateway API",
+ "version": "1",
+ "description": "The HTTP + SSE face of the workflow protocol: start and follow Runs, answer questions and approvals, read\nresults and transcripts. The same resources and commands are also OpenCode plugin RPC methods\n(`POST /api/rpc/workflow/` on the OpenCode server), which an OpenCode client can use with its\nexisting credentials. Guide: https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/integrating.md\n\nWithin protocol v1 changes are additive only: new optional fields, event types and error codes. Ignore\nwhat you do not know.\n\nAuth: reads need a `read` token, except from the same machine to a loopback Gateway; every action (start,\nstop, answer, …) needs a `control` token. See the bearer scheme and the guide.",
+ "license": {
+ "name": "MIT",
+ "identifier": "MIT"
+ }
+ },
+ "servers": [
+ {
+ "url": "http://127.0.0.1:4320",
+ "description": "The default local Gateway (the next free port if taken)"
+ }
+ ],
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "paths": {
+ "/v1/openapi.json": {
+ "get": {
+ "operationId": "openapi",
+ "summary": "This document",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The OpenAPI document",
+ "content": {
+ "application/json": {}
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/info": {
+ "get": {
+ "operationId": "info",
+ "summary": "Versions, capabilities and limits of every location this process serves, and who the caller is",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GatewayInfoOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/events": {
+ "get": {
+ "operationId": "events",
+ "summary": "The event stream of one location (Server-Sent Events)",
+ "description": "Each SSE message has `id: .`, `event: ` and `data: ` (JSON).\nReconnect with the `Last-Event-ID` header (or `?after=.`) to receive what was missed; when the\ngap cannot be filled (the service restarted, or the buffer moved on) a `resync.required` event arrives:\nre-read `GET /v1/runs` and the Runs you show.\n\nAn open stream also marks the location as **attached**: while one is open, questions and inline-Workflow\napprovals wait for an answer instead of being refused or taking their fallback.",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "location",
+ "in": "query",
+ "required": false,
+ "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "after",
+ "in": "query",
+ "required": false,
+ "description": "Resume after this event id (`.`); the `Last-Event-ID` header does the same.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "An endless `text/event-stream`; every `data` line is a ProtocolEvent",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolEvent"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/workflows": {
+ "get": {
+ "operationId": "listWorkflows",
+ "summary": "The durable (saved) Workflows of a location, with each one's args JSON Schema",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "location",
+ "in": "query",
+ "required": false,
+ "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ListWorkflowsOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs": {
+ "get": {
+ "operationId": "listRuns",
+ "summary": "Runs, newest first (live and journaled)",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "location",
+ "in": "query",
+ "required": false,
+ "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "description": "Only Runs in these states (repeat the parameter for several).",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "string",
+ "enum": [
+ "queued",
+ "running",
+ "succeeded",
+ "failed",
+ "stopped",
+ "interrupted"
+ ]
+ }
+ },
+ "style": "form",
+ "explode": true
+ },
+ {
+ "name": "search",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "parentSessionID",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "since",
+ "in": "query",
+ "required": false,
+ "description": "Only Runs started at or after this time (ms since the epoch).",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "number",
+ "default": 200
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ListRunsOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "invalid_args: the request does not match the schema",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "operationId": "startRun",
+ "summary": "Start a Run: a durable Workflow by `name`, or inline `source`",
+ "description": "Returns at once; follow the Run on the event stream. Inline source needs approval unless the project allows it (plugin option `inline`); the approval is a pending interaction on the Run.",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "location",
+ "in": "query",
+ "required": false,
+ "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StartRunInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "202": {
+ "description": "Accepted: the Run is starting",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StartRunOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "invalid_args: the request does not match the schema",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "rate_limited",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}": {
+ "get": {
+ "operationId": "getRun",
+ "summary": "A Run in full (Units' outputs may be elided; fetch the Unit for the whole value)",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetRunOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/result": {
+ "get": {
+ "operationId": "getResult",
+ "summary": "What the Workflow returned",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetResultOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/activity": {
+ "get": {
+ "operationId": "getActivity",
+ "summary": "The Run's activity: logs, phases, engine and capability lines",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ActivityOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/units/{unitId}": {
+ "get": {
+ "operationId": "getUnit",
+ "summary": "One Unit with its whole output",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "unitId",
+ "in": "path",
+ "required": true,
+ "description": "The Unit's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetUnitOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/units/{unitId}/transcript": {
+ "get": {
+ "operationId": "getTranscript",
+ "summary": "The Unit's session: messages, reasoning and tool calls",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "unitId",
+ "in": "path",
+ "required": true,
+ "description": "The Unit's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetTranscriptOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/stop": {
+ "post": {
+ "operationId": "stopRun",
+ "summary": "Stop a Run (running Units are interrupted)",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "rate_limited",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/resume": {
+ "post": {
+ "operationId": "resumeRun",
+ "summary": "Resume a finished Run from its journal as a new Run (finished Units replay)",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ResumeRunBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "202": {
+ "description": "Accepted: the new Run is starting",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StartRunOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/save": {
+ "post": {
+ "operationId": "saveRun",
+ "summary": "Save the Run's script as a durable Workflow",
+ "description": "Inline source that no person approved to run needs approval to be saved (a saved Workflow runs without approval): the call waits for it.",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SaveRunBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SaveRunOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "invalid_args: the request does not match the schema",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/cleanup": {
+ "post": {
+ "operationId": "cleanupRun",
+ "summary": "Mark a finished Run's Unit sessions for deletion, or record the ones a client deleted",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CleanupRunBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CleanupRunOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/units/{unitId}/stop": {
+ "post": {
+ "operationId": "stopUnit",
+ "summary": "Stop one Unit (it resolves to null in the script)",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "unitId",
+ "in": "path",
+ "required": true,
+ "description": "The Unit's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/units/{unitId}/restart": {
+ "post": {
+ "operationId": "restartUnit",
+ "summary": "Restart one Unit in a fresh session",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "unitId",
+ "in": "path",
+ "required": true,
+ "description": "The Unit's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/interactions/{interactionId}/reply": {
+ "post": {
+ "operationId": "replyInteraction",
+ "summary": "Answer a pending question, permission request or inline-Workflow approval",
+ "description": "`answers` has one entry per question, each a list of chosen option labels (or free text where the question allows `custom`). An approval's options are `Run once`, `Always for this project` and `Reject` (to save: `Save`, `Reject`).",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "interactionId",
+ "in": "path",
+ "required": true,
+ "description": "The pending interaction's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReplyBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "invalid_args: the request does not match the schema",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/runs/{runId}/interactions/{interactionId}/cancel": {
+ "post": {
+ "operationId": "cancelInteraction",
+ "summary": "Dismiss a pending interaction (a script question takes its fallback; an approval is refused)",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "description": "The Run's id.",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "interactionId",
+ "in": "path",
+ "required": true,
+ "description": "The pending interaction's id.",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "not_found: no such Run, Unit, Workflow or route",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "conflict or invalid_state: the Run or interaction is not in a state for this",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/pair": {
+ "post": {
+ "operationId": "pair",
+ "summary": "Exchange a one-use pairing code (from `/workflows pair` in the TUI) for a token",
+ "security": [
+ {}
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PairInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PairOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "unauthorized: a token is missing, unknown or revoked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "rate_limited",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/pair/local": {
+ "post": {
+ "operationId": "pairLocal",
+ "summary": "A browser on this machine, on the Gateway's own origin, pairs itself",
+ "security": [
+ {}
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PairOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolError"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "bearer": {
+ "type": "http",
+ "scheme": "bearer",
+ "description": "A device token from pairing: `read` scope for reads, `control` scope for every action. A client on the same machine as a loopback Gateway may read without one. Tokens never go in URLs."
+ }
+ },
+ "schemas": {
+ "GatewayInfoOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "protocol": {
+ "type": "number",
+ "const": 1
+ },
+ "plugin": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "version"
+ ],
+ "additionalProperties": false
+ },
+ "locations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string"
+ },
+ "info": {
+ "type": "object",
+ "properties": {
+ "protocol": {
+ "type": "number",
+ "const": 1
+ },
+ "plugin": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "version"
+ ],
+ "additionalProperties": false
+ },
+ "opencode": {
+ "type": "string"
+ },
+ "location": {
+ "type": "string"
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "limits": {
+ "type": "object",
+ "properties": {
+ "maxUnits": {
+ "type": "number"
+ },
+ "maxItemsPerCall": {
+ "type": "number"
+ },
+ "maxUnitSteps": {
+ "type": "number"
+ },
+ "maxConcurrentUnits": {
+ "type": "number"
+ },
+ "maxConcurrentRuns": {
+ "type": "number"
+ },
+ "providerConcurrency": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "number"
+ }
+ }
+ },
+ "required": [
+ "maxUnits",
+ "maxItemsPerCall",
+ "maxUnitSteps"
+ ],
+ "additionalProperties": false
+ },
+ "gateway": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "web": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "url"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "protocol",
+ "plugin",
+ "opencode",
+ "location",
+ "capabilities",
+ "limits",
+ "gateway"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "location",
+ "info"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "auth": {
+ "type": "object",
+ "properties": {
+ "device": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "scopes"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "loopback": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "device",
+ "loopback"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "protocol",
+ "plugin",
+ "locations",
+ "auth"
+ ],
+ "additionalProperties": false
+ },
+ "ListRunsOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "runs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "runId": {
+ "type": "string"
+ },
+ "workflow": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "provenance": {
+ "type": "string",
+ "enum": [
+ "durable",
+ "inline"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "name",
+ "description",
+ "provenance"
+ ],
+ "additionalProperties": false
+ },
+ "location": {
+ "type": "string"
+ },
+ "parentSessionID": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "running",
+ "succeeded",
+ "failed",
+ "stopped",
+ "interrupted"
+ ]
+ },
+ "waiting": {
+ "type": "boolean"
+ },
+ "units": {
+ "type": "number"
+ },
+ "settledUnits": {
+ "type": "number"
+ },
+ "failedUnits": {
+ "type": "number"
+ },
+ "phases": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "phasesDeclared": {
+ "type": "boolean"
+ },
+ "currentPhase": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "usage": {
+ "type": "object",
+ "properties": {
+ "tokens": {
+ "type": "object",
+ "properties": {
+ "input": {
+ "type": "number"
+ },
+ "output": {
+ "type": "number"
+ },
+ "reasoning": {
+ "type": "number"
+ },
+ "cacheRead": {
+ "type": "number"
+ },
+ "cacheWrite": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "input",
+ "output",
+ "reasoning",
+ "cacheRead",
+ "cacheWrite"
+ ],
+ "additionalProperties": false
+ },
+ "cost": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "tokens",
+ "cost"
+ ],
+ "additionalProperties": false
+ },
+ "tokensSpent": {
+ "type": "number"
+ },
+ "startedAt": {
+ "type": "number"
+ },
+ "endedAt": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "live": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "runId",
+ "workflow",
+ "location",
+ "parentSessionID",
+ "status",
+ "waiting",
+ "units",
+ "settledUnits",
+ "failedUnits",
+ "phases",
+ "phasesDeclared",
+ "currentPhase",
+ "usage",
+ "tokensSpent",
+ "startedAt",
+ "endedAt",
+ "live"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "runs"
+ ],
+ "additionalProperties": false
+ },
+ "GetRunOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "properties": {
+ "runId": {
+ "type": "string"
+ },
+ "workflow": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "provenance": {
+ "type": "string",
+ "enum": [
+ "durable",
+ "inline"
+ ]
+ }
+ },
+ "required": [
+ "key",
+ "name",
+ "description",
+ "provenance"
+ ],
+ "additionalProperties": false
+ },
+ "location": {
+ "type": "string"
+ },
+ "parentSessionID": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "running",
+ "succeeded",
+ "failed",
+ "stopped",
+ "interrupted"
+ ]
+ },
+ "waiting": {
+ "type": "boolean"
+ },
+ "background": {
+ "type": "boolean"
+ },
+ "phases": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "phasesDeclared": {
+ "type": "boolean"
+ },
+ "currentPhase": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "units": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "unitId": {
+ "type": "string"
+ },
+ "runId": {
+ "type": "string"
+ },
+ "ordinal": {
+ "type": "number"
+ },
+ "label": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "subagent": {
+ "type": "string"
+ },
+ "phase": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "running",
+ "repairing",
+ "succeeded",
+ "failed",
+ "stopped",
+ "replayed"
+ ]
+ },
+ "sessionID": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "location": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "model": {
+ "type": "object",
+ "properties": {
+ "requested": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "resolved": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "requested",
+ "resolved"
+ ],
+ "additionalProperties": false
+ },
+ "schema": {
+ "type": "boolean"
+ },
+ "resultPath": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "text",
+ "tool",
+ "text-json",
+ "extract",
+ "replay"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "attempts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "turn": {
+ "type": "number"
+ },
+ "path": {
+ "type": "string",
+ "enum": [
+ "text",
+ "tool",
+ "text-json",
+ "extract",
+ "replay"
+ ]
+ },
+ "ok": {
+ "type": "boolean"
+ },
+ "error": {
+ "type": "string"
+ },
+ "note": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "turn",
+ "path",
+ "ok"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "output": {
+ "type": "string"
+ },
+ "outputElided": {
+ "type": "boolean"
+ },
+ "error": {
+ "type": "string"
+ },
+ "usage": {
+ "type": "object",
+ "properties": {
+ "tokens": {
+ "type": "object",
+ "properties": {
+ "input": {
+ "type": "number"
+ },
+ "output": {
+ "type": "number"
+ },
+ "reasoning": {
+ "type": "number"
+ },
+ "cacheRead": {
+ "type": "number"
+ },
+ "cacheWrite": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "input",
+ "output",
+ "reasoning",
+ "cacheRead",
+ "cacheWrite"
+ ],
+ "additionalProperties": false
+ },
+ "cost": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "tokens",
+ "cost"
+ ],
+ "additionalProperties": false
+ },
+ "startedAt": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "endedAt": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "unitId",
+ "runId",
+ "ordinal",
+ "label",
+ "subagent",
+ "phase",
+ "status",
+ "sessionID",
+ "location",
+ "prompt",
+ "model",
+ "schema",
+ "resultPath",
+ "attempts",
+ "usage",
+ "startedAt",
+ "endedAt"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "logs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "unit": {
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "subagent": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "unit",
+ "prompt",
+ "subagent",
+ "error"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "interactions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "interactionId": {
+ "type": "string"
+ },
+ "runId": {
+ "type": "string"
+ },
+ "unitId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "kind": {
+ "type": "string",
+ "enum": [
+ "question",
+ "permission",
+ "approval"
+ ]
+ },
+ "origin": {
+ "type": "string",
+ "enum": [
+ "script",
+ "agent",
+ "permission",
+ "engine"
+ ]
+ },
+ "sessionID": {
+ "type": "string"
+ },
+ "phase": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "questions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "header": {
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "label",
+ "description"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "multiple": {
+ "type": "boolean"
+ },
+ "custom": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "header",
+ "prompt",
+ "options",
+ "multiple",
+ "custom"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "permission": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "resources": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "save": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "requestID": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "action",
+ "resources",
+ "save",
+ "requestID"
+ ],
+ "additionalProperties": false
+ },
+ "approval": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "run",
+ "save"
+ ]
+ },
+ "target": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "sha256": {
+ "type": "string"
+ },
+ "bytes": {
+ "type": "number"
+ },
+ "source": {
+ "type": "string"
+ },
+ "requestingSessionID": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "action",
+ "target",
+ "sha256",
+ "bytes",
+ "source",
+ "requestingSessionID"
+ ],
+ "additionalProperties": false
+ },
+ "form": {
+ "type": "object",
+ "properties": {
+ "formID": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "formID"
+ ],
+ "additionalProperties": false
+ },
+ "raisedAt": {
+ "type": "number"
+ },
+ "graceEndsAt": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "interactionId",
+ "runId",
+ "unitId",
+ "kind",
+ "origin",
+ "sessionID",
+ "phase",
+ "questions",
+ "raisedAt",
+ "graceEndsAt"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "resolved": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "interactionId": {
+ "type": "string"
+ },
+ "runId": {
+ "type": "string"
+ },
+ "unitId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "kind": {
+ "type": "string",
+ "enum": [
+ "question",
+ "permission",
+ "approval"
+ ]
+ },
+ "origin": {
+ "type": "string",
+ "enum": [
+ "script",
+ "agent",
+ "permission",
+ "engine"
+ ]
+ },
+ "sessionID": {
+ "type": "string"
+ },
+ "phase": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "questions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "header": {
+ "type": "string"
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "label",
+ "description"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "multiple": {
+ "type": "boolean"
+ },
+ "custom": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "header",
+ "prompt",
+ "options",
+ "multiple",
+ "custom"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "permission": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "resources": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "save": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "requestID": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "action",
+ "resources",
+ "save",
+ "requestID"
+ ],
+ "additionalProperties": false
+ },
+ "approval": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "run",
+ "save"
+ ]
+ },
+ "target": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "sha256": {
+ "type": "string"
+ },
+ "bytes": {
+ "type": "number"
+ },
+ "source": {
+ "type": "string"
+ },
+ "requestingSessionID": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "action",
+ "target",
+ "sha256",
+ "bytes",
+ "source",
+ "requestingSessionID"
+ ],
+ "additionalProperties": false
+ },
+ "form": {
+ "type": "object",
+ "properties": {
+ "formID": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "formID"
+ ],
+ "additionalProperties": false
+ },
+ "raisedAt": {
+ "type": "number"
+ },
+ "answers": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "by": {
+ "type": "string",
+ "enum": [
+ "human",
+ "automation"
+ ]
+ },
+ "outcome": {
+ "type": "string",
+ "enum": [
+ "answered",
+ "rejected",
+ "cancelled"
+ ]
+ },
+ "resolvedAt": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "interactionId",
+ "runId",
+ "unitId",
+ "kind",
+ "origin",
+ "sessionID",
+ "phase",
+ "questions",
+ "raisedAt",
+ "answers",
+ "by",
+ "resolvedAt"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "usage": {
+ "type": "object",
+ "properties": {
+ "tokens": {
+ "type": "object",
+ "properties": {
+ "input": {
+ "type": "number"
+ },
+ "output": {
+ "type": "number"
+ },
+ "reasoning": {
+ "type": "number"
+ },
+ "cacheRead": {
+ "type": "number"
+ },
+ "cacheWrite": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "input",
+ "output",
+ "reasoning",
+ "cacheRead",
+ "cacheWrite"
+ ],
+ "additionalProperties": false
+ },
+ "cost": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "tokens",
+ "cost"
+ ],
+ "additionalProperties": false
+ },
+ "tokensSpent": {
+ "type": "number"
+ },
+ "budget": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "hard": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "total",
+ "hard"
+ ],
+ "additionalProperties": false
+ },
+ "startedAt": {
+ "type": "number"
+ },
+ "endedAt": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "resultPreview": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "resumeOf": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "cleanup": {
+ "type": "string",
+ "enum": [
+ "none",
+ "pending",
+ "done"
+ ]
+ },
+ "revision": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "runId",
+ "workflow",
+ "location",
+ "parentSessionID",
+ "status",
+ "waiting",
+ "background",
+ "phases",
+ "phasesDeclared",
+ "currentPhase",
+ "units",
+ "logs",
+ "errors",
+ "interactions",
+ "resolved",
+ "usage",
+ "tokensSpent",
+ "budget",
+ "startedAt",
+ "endedAt",
+ "resultPreview",
+ "resumeOf",
+ "cleanup",
+ "revision"
+ ],
+ "additionalProperties": false
+ },
+ "live": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "run",
+ "live"
+ ],
+ "additionalProperties": false
+ },
+ "GetUnitOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "unit": {
+ "type": "object",
+ "properties": {
+ "unitId": {
+ "type": "string"
+ },
+ "runId": {
+ "type": "string"
+ },
+ "ordinal": {
+ "type": "number"
+ },
+ "label": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "subagent": {
+ "type": "string"
+ },
+ "phase": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "running",
+ "repairing",
+ "succeeded",
+ "failed",
+ "stopped",
+ "replayed"
+ ]
+ },
+ "sessionID": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "location": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "model": {
+ "type": "object",
+ "properties": {
+ "requested": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "resolved": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "requested",
+ "resolved"
+ ],
+ "additionalProperties": false
+ },
+ "schema": {
+ "type": "boolean"
+ },
+ "resultPath": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "text",
+ "tool",
+ "text-json",
+ "extract",
+ "replay"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "attempts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "turn": {
+ "type": "number"
+ },
+ "path": {
+ "type": "string",
+ "enum": [
+ "text",
+ "tool",
+ "text-json",
+ "extract",
+ "replay"
+ ]
+ },
+ "ok": {
+ "type": "boolean"
+ },
+ "error": {
+ "type": "string"
+ },
+ "note": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "turn",
+ "path",
+ "ok"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "output": {
+ "type": "string"
+ },
+ "outputElided": {
+ "type": "boolean"
+ },
+ "error": {
+ "type": "string"
+ },
+ "usage": {
+ "type": "object",
+ "properties": {
+ "tokens": {
+ "type": "object",
+ "properties": {
+ "input": {
+ "type": "number"
+ },
+ "output": {
+ "type": "number"
+ },
+ "reasoning": {
+ "type": "number"
+ },
+ "cacheRead": {
+ "type": "number"
+ },
+ "cacheWrite": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "input",
+ "output",
+ "reasoning",
+ "cacheRead",
+ "cacheWrite"
+ ],
+ "additionalProperties": false
+ },
+ "cost": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "tokens",
+ "cost"
+ ],
+ "additionalProperties": false
+ },
+ "startedAt": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "endedAt": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "unitId",
+ "runId",
+ "ordinal",
+ "label",
+ "subagent",
+ "phase",
+ "status",
+ "sessionID",
+ "location",
+ "prompt",
+ "model",
+ "schema",
+ "resultPath",
+ "attempts",
+ "usage",
+ "startedAt",
+ "endedAt"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "unit"
+ ],
+ "additionalProperties": false
+ },
+ "GetResultOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "runId": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "running",
+ "succeeded",
+ "failed",
+ "stopped",
+ "interrupted"
+ ]
+ },
+ "result": {}
+ },
+ "required": [
+ "runId",
+ "status",
+ "result"
+ ],
+ "additionalProperties": false
+ },
+ "ActivityOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "entries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": [
+ "log",
+ "phase",
+ "engine",
+ "capability"
+ ]
+ },
+ "message": {
+ "type": "string"
+ },
+ "time": {
+ "type": "number"
+ },
+ "unitId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "kind",
+ "message",
+ "time",
+ "unitId"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "entries"
+ ],
+ "additionalProperties": false
+ },
+ "GetTranscriptOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "sessionID": {
+ "type": "string"
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "role": {
+ "type": "string",
+ "enum": [
+ "user",
+ "assistant",
+ "system",
+ "other"
+ ]
+ },
+ "parts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": [
+ "text",
+ "reasoning",
+ "tool",
+ "other"
+ ]
+ },
+ "text": {
+ "type": "string"
+ },
+ "tool": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "input": {
+ "type": "string"
+ },
+ "output": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "status"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "model": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "role",
+ "parts",
+ "model",
+ "error"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "clipped": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "sessionID",
+ "messages",
+ "clipped"
+ ],
+ "additionalProperties": false
+ },
+ "ListWorkflowsOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "workflows": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "whenToUse": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "phases": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "detail": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "title",
+ "detail"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "args": {
+ "anyOf": [
+ {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "path": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "key",
+ "name",
+ "description",
+ "whenToUse",
+ "phases",
+ "args",
+ "path",
+ "scope"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "collisions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "kept": {
+ "type": "string"
+ },
+ "shadowed": {
+ "type": "string"
+ },
+ "sameScope": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "key",
+ "kept",
+ "shadowed",
+ "sameScope"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "failures": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "error"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "workflows",
+ "collisions",
+ "failures"
+ ],
+ "additionalProperties": false
+ },
+ "StartRunInput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string"
+ },
+ "args": {},
+ "parentSessionID": {
+ "type": "string"
+ },
+ "requestId": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "StartRunOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "runId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "runId"
+ ],
+ "additionalProperties": false
+ },
+ "ResumeRunBody": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "rerunFailed": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false
+ },
+ "SaveRunBody": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "SaveRunOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "key",
+ "path"
+ ],
+ "additionalProperties": false
+ },
+ "CleanupRunBody": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "deleted": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "CleanupRunOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "deleted": {
+ "type": "number"
+ },
+ "pending": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "deleted",
+ "pending"
+ ],
+ "additionalProperties": false
+ },
+ "ReplyBody": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "answers": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "required": [
+ "answers"
+ ],
+ "additionalProperties": false
+ },
+ "OkOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean",
+ "const": true
+ }
+ },
+ "required": [
+ "ok"
+ ],
+ "additionalProperties": false
+ },
+ "PairInput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "code"
+ ],
+ "additionalProperties": false
+ },
+ "PairOutput": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "token": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "token",
+ "id"
+ ],
+ "additionalProperties": false
+ },
+ "ProtocolEvent": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "protocol": {
+ "type": "number",
+ "const": 1
+ },
+ "seq": {
+ "type": "number"
+ },
+ "epoch": {
+ "type": "string"
+ },
+ "time": {
+ "type": "number"
+ },
+ "location": {
+ "type": "string"
+ },
+ "runId": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "run.started",
+ "run.updated",
+ "run.ended",
+ "unit.updated",
+ "interaction.pending",
+ "interaction.resolved",
+ "activity.appended",
+ "library.changed",
+ "resync.required"
+ ]
+ },
+ "revision": {
+ "type": "number"
+ },
+ "data": {}
+ },
+ "required": [
+ "protocol",
+ "seq",
+ "time",
+ "location",
+ "runId",
+ "type",
+ "revision",
+ "data"
+ ],
+ "additionalProperties": false
+ },
+ "ProtocolError": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string",
+ "enum": [
+ "not_found",
+ "conflict",
+ "invalid_args",
+ "invalid_state",
+ "unauthorized",
+ "forbidden",
+ "rate_limited",
+ "unsupported",
+ "internal"
+ ]
+ },
+ "message": {
+ "type": "string"
+ },
+ "retryable": {
+ "type": "boolean"
+ },
+ "details": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
+ },
+ "required": [
+ "code",
+ "message",
+ "retryable"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "error"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+}
diff --git a/packages/plugin/docs/protocol/schemas/GatewayInfoOutput.json b/packages/plugin/docs/protocol/schemas/GatewayInfoOutput.json
new file mode 100644
index 0000000..7ba8f0f
--- /dev/null
+++ b/packages/plugin/docs/protocol/schemas/GatewayInfoOutput.json
@@ -0,0 +1,198 @@
+{
+ "$id": "https://opencode-dynamic-workflows/protocol/v1/GatewayInfoOutput.json",
+ "title": "GatewayInfoOutput",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "protocol": {
+ "type": "number",
+ "const": 1
+ },
+ "plugin": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "version"
+ ],
+ "additionalProperties": false
+ },
+ "locations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string"
+ },
+ "info": {
+ "type": "object",
+ "properties": {
+ "protocol": {
+ "type": "number",
+ "const": 1
+ },
+ "plugin": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "version"
+ ],
+ "additionalProperties": false
+ },
+ "opencode": {
+ "type": "string"
+ },
+ "location": {
+ "type": "string"
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "limits": {
+ "type": "object",
+ "properties": {
+ "maxUnits": {
+ "type": "number"
+ },
+ "maxItemsPerCall": {
+ "type": "number"
+ },
+ "maxUnitSteps": {
+ "type": "number"
+ },
+ "maxConcurrentUnits": {
+ "type": "number"
+ },
+ "maxConcurrentRuns": {
+ "type": "number"
+ },
+ "providerConcurrency": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "number"
+ }
+ }
+ },
+ "required": [
+ "maxUnits",
+ "maxItemsPerCall",
+ "maxUnitSteps"
+ ],
+ "additionalProperties": false
+ },
+ "gateway": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "web": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "url"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "protocol",
+ "plugin",
+ "opencode",
+ "location",
+ "capabilities",
+ "limits",
+ "gateway"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "location",
+ "info"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "auth": {
+ "type": "object",
+ "properties": {
+ "device": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "scopes"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "loopback": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "device",
+ "loopback"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "protocol",
+ "plugin",
+ "locations",
+ "auth"
+ ],
+ "additionalProperties": false
+}
diff --git a/packages/plugin/docs/protocol/schemas/InfoOutput.json b/packages/plugin/docs/protocol/schemas/InfoOutput.json
index fb54f6f..ea5ad2d 100644
--- a/packages/plugin/docs/protocol/schemas/InfoOutput.json
+++ b/packages/plugin/docs/protocol/schemas/InfoOutput.json
@@ -83,6 +83,9 @@
"type": "null"
}
]
+ },
+ "web": {
+ "type": "boolean"
}
},
"required": [
diff --git a/packages/plugin/docs/security.md b/packages/plugin/docs/security.md
index d054950..cdd4d4d 100644
--- a/packages/plugin/docs/security.md
+++ b/packages/plugin/docs/security.md
@@ -89,7 +89,8 @@ Rules the Gateway enforces:
(`POST /v1/pair/local`). A remote browser needs a one-use code from the TUI (`/workflows pair`), valid for
five minutes, exchanged at `POST /v1/pair`.
- Tokens are stored as SHA-256 hashes in `$XDG_STATE_HOME/opencode-dynamic-workflows/gateway-tokens.json`
- (mode 0600). Delete an entry to revoke it.
+ (mode 0600). Delete an entry to revoke it: the running Gateway re-reads the file when it changes. A missing or
+ unreadable file grants nothing.
- Writes are rate-limited per client. Control actions are recorded in the Run's activity with the device name.
- Responses carry a strict Content-Security-Policy, `nosniff`, `no-referrer` and `frame-ancestors 'none'`.
diff --git a/packages/plugin/script/protocol-schemas.ts b/packages/plugin/script/protocol-schemas.ts
index f516e03..a8d51a9 100644
--- a/packages/plugin/script/protocol-schemas.ts
+++ b/packages/plugin/script/protocol-schemas.ts
@@ -1,5 +1,6 @@
/**
- * Writes `docs/protocol/schemas/.json` from the protocol's zod schemas (the single source of truth).
+ * Writes `docs/protocol/schemas/.json` and `docs/protocol/openapi.json` from the protocol's zod schemas (the
+ * single source of truth).
* `--check` fails when the committed files are out of date.
*/
import { mkdir, readFile, writeFile } from "node:fs/promises"
@@ -8,6 +9,7 @@ import path from "node:path"
import { z } from "zod"
import { PROTOCOL_VERSION, PUBLISHED_SCHEMAS } from "../src/protocol"
+import { openApiDocument } from "../src/protocol/openapi"
const out = path.join(import.meta.dir, "..", "docs", "protocol", "schemas")
const check = process.argv.includes("--check")
@@ -25,6 +27,12 @@ for (const [name, schema] of Object.entries(PUBLISHED_SCHEMAS)) {
if ((await readFile(file, "utf8").catch(() => "")) !== text) stale.push(name)
} else await writeFile(file, text)
}
+// The Gateway's OpenAPI document, beside the schemas.
+const openapiFile = path.join(out, "..", "openapi.json")
+const openapiText = `${JSON.stringify(openApiDocument(), null, 2)}\n`
+if (check) {
+ if ((await readFile(openapiFile, "utf8").catch(() => "")) !== openapiText) stale.push("openapi.json")
+} else await writeFile(openapiFile, openapiText)
if (stale.length) {
console.error(`protocol schemas out of date: ${stale.join(", ")} — run bun run script/protocol-schemas.ts`)
process.exit(1)
diff --git a/packages/plugin/src/gateway/auth.ts b/packages/plugin/src/gateway/auth.ts
index 3d2f894..f2c9a03 100644
--- a/packages/plugin/src/gateway/auth.ts
+++ b/packages/plugin/src/gateway/auth.ts
@@ -5,7 +5,7 @@
* expire in five minutes; tokens are stored as SHA-256 hashes in a 0600 file under the user's state directory.
*/
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
-import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
+import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
import os from "node:os"
import path from "node:path"
@@ -47,53 +47,87 @@ export async function createTokenStore(
file = path.join(gatewayStateDir(), "gateway-tokens.json"),
): Promise {
let tokens: DeviceToken[] = []
- try {
- const parsed = JSON.parse(await readFile(file, "utf8")) as { tokens?: DeviceToken[] }
- tokens = Array.isArray(parsed.tokens)
- ? parsed.tokens.filter((t) => typeof t.hash === "string" && typeof t.id === "string")
- : []
- } catch {
- tokens = []
+ /**
+ * The file's modification and status-change times, size and mode at the last load: a change means someone edited
+ * it, or changed its permissions (ctime moves on chmod), and it must be read again.
+ */
+ let loadedStamp: string | null = null
+ const stamp = (): string | null => {
+ try {
+ const stat = statSync(file)
+ return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}:${stat.mode}`
+ } catch {
+ return null
+ }
}
+ /**
+ * Re-read the file when it changed since the last load, so deleting an entry revokes that token at once, in the
+ * running Gateway. A missing or unreadable file means no tokens: a broken file never grants access.
+ */
+ const load = () => {
+ const current = stamp()
+ if (current !== null && current === loadedStamp) return
+ loadedStamp = current
+ try {
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as { tokens?: DeviceToken[] }
+ tokens = Array.isArray(parsed.tokens)
+ ? parsed.tokens.filter((t) => typeof t.hash === "string" && typeof t.id === "string")
+ : []
+ } catch {
+ tokens = []
+ }
+ }
+ load()
const codes = new Map()
- const persist = async () => {
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
- const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`
- await writeFile(temp, `${JSON.stringify({ tokens }, null, 2)}\n`, { mode: 0o600 })
- await rename(temp, file)
+ /**
+ * Change the tokens and write them back. Read, change and replace happen synchronously, with nothing awaited in
+ * between, so an edit made to the file meanwhile (a revocation) is never overwritten with a stale list.
+ */
+ const mutate = (change: (current: DeviceToken[]) => { next: DeviceToken[]; value: T }): T => {
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
+ load()
+ const { next, value } = change(tokens)
+ if (next !== tokens) {
+ tokens = next
+ const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`
+ writeFileSync(temp, `${JSON.stringify({ tokens }, null, 2)}\n`, { mode: 0o600 })
+ renameSync(temp, file)
+ loadedStamp = stamp()
+ }
+ return value
}
const store: TokenStore = {
async issue(name, scopes) {
const token = `wfg_${randomBytes(24).toString("base64url")}`
const id = randomBytes(6).toString("hex")
- tokens.push({
+ const entry: DeviceToken = {
id,
name: name.slice(0, 80) || "device",
hash: hash(token),
scopes,
createdAt: Date.now(),
lastUsedAt: null,
- })
- await persist()
- return { token, id }
+ }
+ return mutate((current) => ({ next: [...current, entry], value: { token, id } }))
},
verify(token) {
if (!token.startsWith("wfg_")) return null
+ load()
const digest = hash(token)
const match = tokens.find((candidate) => sameHash(candidate.hash, digest))
if (match) match.lastUsedAt = Date.now()
return match ?? null
},
async revoke(id) {
- const before = tokens.length
- tokens = tokens.filter((token) => token.id !== id)
- if (tokens.length === before) return false
- await persist()
- return true
+ return mutate((current) => {
+ const next = current.filter((token) => token.id !== id)
+ return next.length === current.length ? { next: current, value: false } : { next, value: true }
+ })
},
list() {
+ load()
return tokens.map((token) => ({ ...token, scopes: [...token.scopes] }))
},
createPairingCode(scopes = ["read", "control"]) {
diff --git a/packages/plugin/src/gateway/server.ts b/packages/plugin/src/gateway/server.ts
index be7fd7b..3c06012 100644
--- a/packages/plugin/src/gateway/server.ts
+++ b/packages/plugin/src/gateway/server.ts
@@ -28,6 +28,7 @@ import {
type ProtocolErrorCode,
type ProtocolEvent,
} from "../protocol"
+import { openApiDocument } from "../protocol/openapi"
import { elideEvent } from "../runs"
import type { GatewayConfig } from "../service/config"
import { PLUGIN_NAME, PLUGIN_VERSION, type WorkflowService } from "../service/service"
@@ -35,6 +36,8 @@ import { bearer, createTokenStore, isLoopbackAddress, type Scope, type TokenStor
export interface GatewayHandle {
url: string
+ /** Serves the web app (`gateway.web` of the location that started this process-wide Gateway). */
+ web: boolean
host: string
port: number
register(location: string, service: WorkflowService): () => void
@@ -404,6 +407,7 @@ export async function startGateway(config: GatewayConfig, options: GatewayOption
const location = url.searchParams.get("location")
if (request.method === "GET") {
+ if (parts[0] === "openapi.json" && parts.length === 1) return json(openApiDocument(baseUrl), 200, extra)
if (parts[0] === "info" && parts.length === 1) {
return json(
{
@@ -543,6 +547,7 @@ export async function startGateway(config: GatewayConfig, options: GatewayOption
const gateway: GatewayHandle = {
url: baseUrl,
+ web: config.web,
host: hostname,
port,
tokens,
diff --git a/packages/plugin/src/host/plugin.ts b/packages/plugin/src/host/plugin.ts
index c8f0664..4af92cf 100644
--- a/packages/plugin/src/host/plugin.ts
+++ b/packages/plugin/src/host/plugin.ts
@@ -203,6 +203,7 @@ function ensureService(
set: async () => ctx.storage.set(approvalKey, true),
},
gatewayUrl: () => (engineGlobal().singletons.get("gateway") as GatewayHandle | undefined)?.url ?? null,
+ gatewayWeb: () => (engineGlobal().singletons.get("gateway") as GatewayHandle | undefined)?.web ?? false,
})
holder.gatewayAttached = () =>
(engineGlobal().singletons.get("gateway") as GatewayHandle | undefined)?.attached(location) ?? false
@@ -241,7 +242,9 @@ export async function setup(ctx: Context): Promise<() => Promise> {
// Tools
// --------------------------------------------------------------------------------------------------------------
- const link = (runId: string) => runLink(service.deps.gatewayUrl(), runId)
+ // No link to a web app that is turned off. The Gateway is shared by every location and keeps the settings of the
+ // one that started it, so ask the running Gateway, not this location's config.
+ const link = (runId: string) => (service.deps.gatewayWeb?.() ? runLink(service.deps.gatewayUrl(), runId) : null)
const mirrorProgress = (runId: string, tc: ToolContext) => {
let last = ""
diff --git a/packages/plugin/src/protocol/index.ts b/packages/plugin/src/protocol/index.ts
index 2ec549a..7d23da7 100644
--- a/packages/plugin/src/protocol/index.ts
+++ b/packages/plugin/src/protocol/index.ts
@@ -389,10 +389,30 @@ export const InfoOutput = z.object({
maxConcurrentRuns: z.number().optional(),
providerConcurrency: z.record(z.string(), z.number()).optional(),
}),
- gateway: z.object({ url: z.string().nullable() }),
+ gateway: z.object({
+ url: z.string().nullable(),
+ /** Whether the Gateway serves the web app (`gateway.web`); `url` may still serve the API when it does not. */
+ web: z.boolean().optional(),
+ }),
})
export type InfoOutput = z.infer
+/** The Gateway's `GET /v1/info`: every location the process serves, and who the caller is. */
+export const GatewayInfoOutput = z.object({
+ protocol: z.literal(PROTOCOL_VERSION),
+ plugin: z.object({ name: z.string(), version: z.string() }),
+ locations: z.array(z.object({ location: z.string(), info: InfoOutput })),
+ auth: z.object({
+ device: z.object({ id: z.string(), name: z.string(), scopes: z.array(z.string()) }).nullable(),
+ loopback: z.boolean(),
+ }),
+})
+export type GatewayInfoOutput = z.infer
+
+/** `POST /v1/pair` (a one-use code from `/workflows pair`) and `POST /v1/pair/local`. */
+export const PairInput = z.object({ code: z.string(), name: z.string().optional() })
+export const PairOutput = z.object({ token: z.string(), id: z.string() })
+
export const ListRunsInput = z.object({
status: z.array(RunStatus).optional(),
parentSessionID: z.string().optional(),
@@ -525,6 +545,7 @@ export const PUBLISHED_SCHEMAS = {
ProtocolEvent,
ProtocolError,
InfoOutput,
+ GatewayInfoOutput,
ListRunsInput,
StartRunInput,
ReplyInteractionInput,
diff --git a/packages/plugin/src/protocol/openapi.ts b/packages/plugin/src/protocol/openapi.ts
new file mode 100644
index 0000000..b6af86e
--- /dev/null
+++ b/packages/plugin/src/protocol/openapi.ts
@@ -0,0 +1,406 @@
+/**
+ * The Gateway's HTTP API as an OpenAPI 3.1 document, built from the protocol's zod schemas (the same source as the
+ * JSON Schemas and the RPC contract). Served at `GET /v1/openapi.json` and written to `docs/protocol/openapi.json`.
+ *
+ * `info.version` is the protocol version, not the package version: the document changes only when the API does.
+ */
+import { z } from "zod"
+
+import {
+ ActivityOutput,
+ CleanupRunOutput,
+ GatewayInfoOutput,
+ GetResultOutput,
+ GetRunOutput,
+ GetTranscriptOutput,
+ GetUnitOutput,
+ ListRunsOutput,
+ ListWorkflowsOutput,
+ OkOutput,
+ PairInput,
+ PairOutput,
+ ProtocolError,
+ ProtocolEvent,
+ PROTOCOL_VERSION,
+ RunStatus,
+ SaveRunOutput,
+ StartRunInput,
+ StartRunOutput,
+} from "./index"
+
+type Schema = z.ZodType
+type Json = Record
+
+/** The schemas the document names; each becomes `#/components/schemas/`. */
+const COMPONENTS: Record = {
+ GatewayInfoOutput,
+ ListRunsOutput,
+ GetRunOutput,
+ GetUnitOutput,
+ GetResultOutput,
+ ActivityOutput,
+ GetTranscriptOutput,
+ ListWorkflowsOutput,
+ StartRunInput,
+ StartRunOutput,
+ ResumeRunBody: z.object({
+ /** Re-run Units that failed or were stopped instead of replaying them as failures. Default true. */
+ rerunFailed: z.boolean().optional(),
+ }),
+ SaveRunBody: z.object({ name: z.string().optional() }),
+ SaveRunOutput,
+ CleanupRunBody: z.object({ deleted: z.array(z.string()).optional() }),
+ CleanupRunOutput,
+ ReplyBody: z.object({ answers: z.array(z.array(z.string())) }),
+ OkOutput,
+ PairInput,
+ PairOutput,
+ ProtocolEvent,
+ ProtocolError: z.object({ error: ProtocolError }),
+}
+
+const ref = (name: keyof typeof COMPONENTS | string) => ({ $ref: `#/components/schemas/${name}` })
+
+const jsonBody = (name: string) => ({ required: true, content: { "application/json": { schema: ref(name) } } })
+const ok = (name: string, status = "200", description = "OK") => ({
+ [status]: { description, content: { "application/json": { schema: ref(name) } } },
+})
+const errors = (...codes: number[]) =>
+ Object.fromEntries(
+ codes.map((code) => [
+ String(code),
+ {
+ description: ERROR_TEXT[code] ?? "Error",
+ content: { "application/json": { schema: ref("ProtocolError") } },
+ },
+ ]),
+ )
+const ERROR_TEXT: Record = {
+ 400: "invalid_args: the request does not match the schema",
+ 401: "unauthorized: a token is missing, unknown or revoked",
+ 403: "forbidden: the token lacks the scope, or the origin or host is not allowed",
+ 404: "not_found: no such Run, Unit, Workflow or route",
+ 409: "conflict or invalid_state: the Run or interaction is not in a state for this",
+ 429: "rate_limited",
+}
+
+const param = (name: string, description: string, required = true) => ({
+ name,
+ in: "path",
+ required,
+ description,
+ schema: { type: "string" },
+})
+const location = {
+ name: "location",
+ in: "query",
+ required: false,
+ description:
+ "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.",
+ schema: { type: "string" },
+}
+const runId = param("runId", "The Run's id.")
+const unitId = param("unitId", "The Unit's id.")
+const interactionId = param("interactionId", "The pending interaction's id.")
+
+/**
+ * Reads need a token with `read` scope. A client on the same machine as a loopback Gateway may omit it: OpenAPI
+ * cannot express a network-dependent exception, so it is documented (in `info` and the bearer scheme) instead.
+ */
+const READ = [{ bearer: [] }]
+/** Control actions: always a token with `control` scope (or `gateway.auth: "none"` on a loopback bind). */
+const CONTROL = [{ bearer: [] }]
+
+const PATHS: Json = {
+ "/v1/openapi.json": {
+ get: {
+ operationId: "openapi",
+ summary: "This document",
+ security: READ,
+ responses: {
+ "200": { description: "The OpenAPI document", content: { "application/json": {} } },
+ ...errors(401),
+ },
+ },
+ },
+ "/v1/info": {
+ get: {
+ operationId: "info",
+ summary: "Versions, capabilities and limits of every location this process serves, and who the caller is",
+ security: READ,
+ responses: { ...ok("GatewayInfoOutput"), ...errors(401) },
+ },
+ },
+ "/v1/events": {
+ get: {
+ operationId: "events",
+ summary: "The event stream of one location (Server-Sent Events)",
+ description: [
+ "Each SSE message has `id: .`, `event: ` and `data: ` (JSON).",
+ "Reconnect with the `Last-Event-ID` header (or `?after=.`) to receive what was missed; when the",
+ "gap cannot be filled (the service restarted, or the buffer moved on) a `resync.required` event arrives:",
+ "re-read `GET /v1/runs` and the Runs you show.",
+ "",
+ "An open stream also marks the location as **attached**: while one is open, questions and inline-Workflow",
+ "approvals wait for an answer instead of being refused or taking their fallback.",
+ ].join("\n"),
+ security: READ,
+ parameters: [
+ location,
+ {
+ name: "after",
+ in: "query",
+ required: false,
+ description: "Resume after this event id (`.`); the `Last-Event-ID` header does the same.",
+ schema: { type: "string" },
+ },
+ ],
+ responses: {
+ "200": {
+ description: "An endless `text/event-stream`; every `data` line is a ProtocolEvent",
+ content: { "text/event-stream": { schema: ref("ProtocolEvent") } },
+ },
+ ...errors(401, 404),
+ },
+ },
+ },
+ "/v1/workflows": {
+ get: {
+ operationId: "listWorkflows",
+ summary: "The durable (saved) Workflows of a location, with each one's args JSON Schema",
+ security: READ,
+ parameters: [location],
+ responses: { ...ok("ListWorkflowsOutput"), ...errors(401, 404) },
+ },
+ },
+ "/v1/runs": {
+ get: {
+ operationId: "listRuns",
+ summary: "Runs, newest first (live and journaled)",
+ security: READ,
+ parameters: [
+ location,
+ {
+ name: "status",
+ in: "query",
+ required: false,
+ description: "Only Runs in these states (repeat the parameter for several).",
+ schema: { type: "array", items: z.toJSONSchema(RunStatus) },
+ style: "form",
+ explode: true,
+ },
+ { name: "search", in: "query", required: false, schema: { type: "string" } },
+ { name: "parentSessionID", in: "query", required: false, schema: { type: "string" } },
+ {
+ name: "since",
+ in: "query",
+ required: false,
+ description: "Only Runs started at or after this time (ms since the epoch).",
+ schema: { type: "number" },
+ },
+ { name: "limit", in: "query", required: false, schema: { type: "number", default: 200 } },
+ ],
+ responses: { ...ok("ListRunsOutput"), ...errors(400, 401) },
+ },
+ post: {
+ operationId: "startRun",
+ summary: "Start a Run: a durable Workflow by `name`, or inline `source`",
+ description:
+ "Returns at once; follow the Run on the event stream. Inline source needs approval unless the project allows it (plugin option `inline`); the approval is a pending interaction on the Run.",
+ security: CONTROL,
+ parameters: [location],
+ requestBody: jsonBody("StartRunInput"),
+ responses: {
+ ...ok("StartRunOutput", "202", "Accepted: the Run is starting"),
+ ...errors(400, 401, 403, 404, 429),
+ },
+ },
+ },
+ "/v1/runs/{runId}": {
+ get: {
+ operationId: "getRun",
+ summary: "A Run in full (Units' outputs may be elided; fetch the Unit for the whole value)",
+ security: READ,
+ parameters: [runId],
+ responses: { ...ok("GetRunOutput"), ...errors(401, 404) },
+ },
+ },
+ "/v1/runs/{runId}/result": {
+ get: {
+ operationId: "getResult",
+ summary: "What the Workflow returned",
+ security: READ,
+ parameters: [runId],
+ responses: { ...ok("GetResultOutput"), ...errors(401, 404, 409) },
+ },
+ },
+ "/v1/runs/{runId}/activity": {
+ get: {
+ operationId: "getActivity",
+ summary: "The Run's activity: logs, phases, engine and capability lines",
+ security: READ,
+ parameters: [runId],
+ responses: { ...ok("ActivityOutput"), ...errors(401, 404) },
+ },
+ },
+ "/v1/runs/{runId}/units/{unitId}": {
+ get: {
+ operationId: "getUnit",
+ summary: "One Unit with its whole output",
+ security: READ,
+ parameters: [runId, unitId],
+ responses: { ...ok("GetUnitOutput"), ...errors(401, 404) },
+ },
+ },
+ "/v1/runs/{runId}/units/{unitId}/transcript": {
+ get: {
+ operationId: "getTranscript",
+ summary: "The Unit's session: messages, reasoning and tool calls",
+ security: READ,
+ parameters: [runId, unitId],
+ responses: { ...ok("GetTranscriptOutput"), ...errors(401, 404) },
+ },
+ },
+ "/v1/runs/{runId}/stop": {
+ post: {
+ operationId: "stopRun",
+ summary: "Stop a Run (running Units are interrupted)",
+ security: CONTROL,
+ parameters: [runId],
+ responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409, 429) },
+ },
+ },
+ "/v1/runs/{runId}/resume": {
+ post: {
+ operationId: "resumeRun",
+ summary: "Resume a finished Run from its journal as a new Run (finished Units replay)",
+ security: CONTROL,
+ parameters: [runId],
+ requestBody: { ...jsonBody("ResumeRunBody"), required: false },
+ responses: { ...ok("StartRunOutput", "202", "Accepted: the new Run is starting"), ...errors(401, 403, 404, 409) },
+ },
+ },
+ "/v1/runs/{runId}/save": {
+ post: {
+ operationId: "saveRun",
+ summary: "Save the Run's script as a durable Workflow",
+ description:
+ "Inline source that no person approved to run needs approval to be saved (a saved Workflow runs without approval): the call waits for it.",
+ security: CONTROL,
+ parameters: [runId],
+ requestBody: { ...jsonBody("SaveRunBody"), required: false },
+ responses: { ...ok("SaveRunOutput"), ...errors(400, 401, 403, 404, 409) },
+ },
+ },
+ "/v1/runs/{runId}/cleanup": {
+ post: {
+ operationId: "cleanupRun",
+ summary: "Mark a finished Run's Unit sessions for deletion, or record the ones a client deleted",
+ security: CONTROL,
+ parameters: [runId],
+ requestBody: { ...jsonBody("CleanupRunBody"), required: false },
+ responses: { ...ok("CleanupRunOutput"), ...errors(401, 403, 404, 409) },
+ },
+ },
+ "/v1/runs/{runId}/units/{unitId}/stop": {
+ post: {
+ operationId: "stopUnit",
+ summary: "Stop one Unit (it resolves to null in the script)",
+ security: CONTROL,
+ parameters: [runId, unitId],
+ responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409) },
+ },
+ },
+ "/v1/runs/{runId}/units/{unitId}/restart": {
+ post: {
+ operationId: "restartUnit",
+ summary: "Restart one Unit in a fresh session",
+ security: CONTROL,
+ parameters: [runId, unitId],
+ responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409) },
+ },
+ },
+ "/v1/runs/{runId}/interactions/{interactionId}/reply": {
+ post: {
+ operationId: "replyInteraction",
+ summary: "Answer a pending question, permission request or inline-Workflow approval",
+ description:
+ "`answers` has one entry per question, each a list of chosen option labels (or free text where the question allows `custom`). An approval's options are `Run once`, `Always for this project` and `Reject` (to save: `Save`, `Reject`).",
+ security: CONTROL,
+ parameters: [runId, interactionId],
+ requestBody: jsonBody("ReplyBody"),
+ responses: { ...ok("OkOutput"), ...errors(400, 401, 403, 404, 409) },
+ },
+ },
+ "/v1/runs/{runId}/interactions/{interactionId}/cancel": {
+ post: {
+ operationId: "cancelInteraction",
+ summary: "Dismiss a pending interaction (a script question takes its fallback; an approval is refused)",
+ security: CONTROL,
+ parameters: [runId, interactionId],
+ responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409) },
+ },
+ },
+ "/v1/pair": {
+ post: {
+ operationId: "pair",
+ summary: "Exchange a one-use pairing code (from `/workflows pair` in the TUI) for a token",
+ security: [{}],
+ requestBody: jsonBody("PairInput"),
+ responses: { ...ok("PairOutput"), ...errors(401, 429) },
+ },
+ },
+ "/v1/pair/local": {
+ post: {
+ operationId: "pairLocal",
+ summary: "A browser on this machine, on the Gateway's own origin, pairs itself",
+ security: [{}],
+ responses: { ...ok("PairOutput"), ...errors(403) },
+ },
+ },
+}
+
+/** `serverUrl`: the Gateway serving the document (its real address); the committed copy uses the default. */
+export function openApiDocument(serverUrl?: string): Json {
+ return {
+ openapi: "3.1.0",
+ info: {
+ title: "OpenCode dynamic workflows: Gateway API",
+ version: `${PROTOCOL_VERSION}`,
+ description: [
+ "The HTTP + SSE face of the workflow protocol: start and follow Runs, answer questions and approvals, read",
+ "results and transcripts. The same resources and commands are also OpenCode plugin RPC methods",
+ "(`POST /api/rpc/workflow/` on the OpenCode server), which an OpenCode client can use with its",
+ "existing credentials. Guide: https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/integrating.md",
+ "",
+ "Within protocol v1 changes are additive only: new optional fields, event types and error codes. Ignore",
+ "what you do not know.",
+ "",
+ "Auth: reads need a `read` token, except from the same machine to a loopback Gateway; every action (start,",
+ "stop, answer, …) needs a `control` token. See the bearer scheme and the guide.",
+ ].join("\n"),
+ license: { name: "MIT", identifier: "MIT" },
+ },
+ servers: serverUrl
+ ? [{ url: serverUrl.replace(/\/$/, ""), description: "This Gateway" }]
+ : [{ url: "http://127.0.0.1:4320", description: "The default local Gateway (the next free port if taken)" }],
+ security: READ,
+ paths: PATHS,
+ components: {
+ securitySchemes: {
+ bearer: {
+ type: "http",
+ scheme: "bearer",
+ description:
+ "A device token from pairing: `read` scope for reads, `control` scope for every action. A client on the same machine as a loopback Gateway may read without one. Tokens never go in URLs.",
+ },
+ },
+ schemas: Object.fromEntries(
+ Object.entries(COMPONENTS).map(([name, schema]) => [
+ name,
+ z.toJSONSchema(schema, { target: "draft-2020-12", unrepresentable: "any" }),
+ ]),
+ ),
+ },
+ }
+}
diff --git a/packages/plugin/src/service/service.ts b/packages/plugin/src/service/service.ts
index 6587e69..0b78c15 100644
--- a/packages/plugin/src/service/service.ts
+++ b/packages/plugin/src/service/service.ts
@@ -60,6 +60,8 @@ export interface ServiceDeps {
/** Project-level "always allow inline Workflows here" flag (backed by plugin storage). */
approvals: { get(): Promise; set(): Promise }
gatewayUrl: () => string | null
+ /** Whether the running Gateway serves the web app (it is shared by every location, with its own settings). */
+ gatewayWeb?: () => boolean
}
export interface StartOptions {
@@ -256,7 +258,7 @@ export class WorkflowService {
maxConcurrentRuns: this.deps.config.maxConcurrentRuns,
providerConcurrency: { ...this.deps.config.providerConcurrency },
},
- gateway: { url: this.deps.gatewayUrl() },
+ gateway: { url: this.deps.gatewayUrl(), web: this.deps.gatewayWeb?.() ?? false },
}
}
diff --git a/packages/plugin/src/tui/app.tsx b/packages/plugin/src/tui/app.tsx
index 67dcca6..3a74c17 100644
--- a/packages/plugin/src/tui/app.tsx
+++ b/packages/plugin/src/tui/app.tsx
@@ -333,6 +333,23 @@ export function setupWorkflowsTui(context: Context): () => void {
async refresh() {
await sync.resync("refresh")
},
+ openInBrowser(url) {
+ const command =
+ process.platform === "darwin"
+ ? ["open", url]
+ : process.platform === "win32"
+ ? ["cmd", "/c", "start", "", url]
+ : ["xdg-open", url]
+ const fallback = () => toast(`Could not open a browser. Open ${url} yourself.`, "warning")
+ try {
+ const child = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "ignore" })
+ // Never keep OpenCode alive for the opener (xdg-open can outlive the click).
+ child.unref()
+ void child.exited.then((code) => (code === 0 ? toast(`Opened ${url}`) : fallback()), fallback)
+ } catch {
+ fallback()
+ }
+ },
openPanel: (runId, sessionID) => openPanel(runId, sessionID),
openRoute: (target) => openRoute(target),
}
diff --git a/packages/plugin/src/tui/state.ts b/packages/plugin/src/tui/state.ts
index c153050..8a6716b 100644
--- a/packages/plugin/src/tui/state.ts
+++ b/packages/plugin/src/tui/state.ts
@@ -42,6 +42,8 @@ export interface SyncState {
readonly runs: Readonly>
/** False until the first snapshot has loaded. */
readonly ready: boolean
+ /** The web app's address (the Gateway), when it is running. */
+ readonly webUrl?: string
}
export type Effect =
@@ -82,6 +84,7 @@ export function fromSnapshot(input: {
entries: readonly LibraryEntry[]
runs: readonly Run[]
previous?: SyncState
+ webUrl?: string | null
}): SyncState {
const runs: Record = {}
for (const entry of input.entries)
@@ -96,7 +99,14 @@ export function fromSnapshot(input: {
for (const [runId, slot] of Object.entries(input.previous?.runs ?? {})) {
if (!runs[runId] && slot.run) runs[runId] = slot
}
- return { location: input.location, seq: input.seq, ...(input.epoch ? { epoch: input.epoch } : {}), runs, ready: true }
+ return {
+ location: input.location,
+ seq: input.seq,
+ ...(input.epoch ? { epoch: input.epoch } : {}),
+ ...(input.webUrl ? { webUrl: input.webUrl } : {}),
+ runs,
+ ready: true,
+ }
}
/** Apply a full `getRun` snapshot (a hydrate or a view opening a Run). Never goes backwards. */
diff --git a/packages/plugin/src/tui/sync.ts b/packages/plugin/src/tui/sync.ts
index 4b762e0..95dfa31 100644
--- a/packages/plugin/src/tui/sync.ts
+++ b/packages/plugin/src/tui/sync.ts
@@ -161,6 +161,7 @@ export class WorkflowSync {
this.set(
fromSnapshot({
location: info.location,
+ webUrl: info.gateway.web === false ? null : info.gateway.url,
seq: latest,
...(epoch ? { epoch } : {}),
entries,
diff --git a/packages/plugin/src/tui/views.tsx b/packages/plugin/src/tui/views.tsx
index 96d7a8d..386fcf8 100644
--- a/packages/plugin/src/tui/views.tsx
+++ b/packages/plugin/src/tui/views.tsx
@@ -99,6 +99,8 @@ export interface Actions {
refresh(): Promise
openPanel(runId?: string, sessionID?: string): void
openRoute(target?: { runId?: string; answer?: boolean }): void
+ /** Open a web app page in the system browser. */
+ openInBrowser(url: string): void
}
export interface Wf {
@@ -378,7 +380,8 @@ export function WorkflowsScreen(props: ScreenProps) {
{ key: "a", label: "answer", run: answerFirst },
{ key: "f", label: `filter: ${nav().filter}`, run: () => void setNav(cycleFilter(nav())) },
{ key: "d", label: "clean up finished", run: () => void wf.actions.cleanupPending() },
- { key: "p", label: "pair browser", run: () => void wf.actions.pair() },
+ ...(webLink() ? [{ key: "b", label: "open in browser", run: () => wf.actions.openInBrowser(webLink()!) }] : []),
+ { key: "p", label: "pair a device", run: () => void wf.actions.pair() },
{ key: "r", label: "refresh", run: () => void wf.actions.refresh() },
...common,
]
@@ -401,6 +404,7 @@ export function WorkflowsScreen(props: ScreenProps) {
{ key: "x", label: "stop unit", run: withUnit((t, u) => wf.actions.stopUnit(t, u)) },
{ key: "r", label: "restart unit", run: withUnit((t, u) => wf.actions.restartUnit(t, u)) },
]),
+ ...(webLink() ? [{ key: "b", label: "open in browser", run: () => wf.actions.openInBrowser(webLink()!) }] : []),
{ key: "p", label: "parent session", run: withRun((t) => wf.actions.openTranscript(t.parentSessionID)) },
...common,
]
@@ -408,6 +412,7 @@ export function WorkflowsScreen(props: ScreenProps) {
return [
{ key: "↑↓", label: "scroll", run: () => moveBy(3) },
{ key: "o", label: "transcript", run: openUnitTranscript },
+ ...(webLink() ? [{ key: "b", label: "open in browser", run: () => wf.actions.openInBrowser(webLink()!) }] : []),
...(terminal
? []
: [
@@ -456,6 +461,13 @@ export function WorkflowsScreen(props: ScreenProps) {
{ title: "Back", group: "Workflows", bind: "backspace", run: goBack },
...(props.escapeBack ? [{ title: "Back", group: "Workflows", bind: "escape", run: goBack }] : []),
{ title: "Answer", group: "Workflows", bind: "a", enabled: inView("any"), run: answerFirst },
+ {
+ title: "Open in the web app",
+ group: "Workflows",
+ bind: "b",
+ enabled: () => !!webLink(),
+ run: () => void (webLink() && wf.actions.openInBrowser(webLink()!)),
+ },
{
title: "Filter",
group: "Workflows",
@@ -594,6 +606,13 @@ export function WorkflowsScreen(props: ScreenProps) {
}
return parts.map((part) => truncate(part, Math.max(12, Math.floor(innerWidth() / parts.length) - 4)))
}
+ // The web app page for what is on screen: the library, or the Run being looked at.
+ const webLink = () => {
+ const base = wf.state().webUrl?.replace(/\/$/, "")
+ if (!base) return null
+ const v = view()
+ return v.kind === "library" ? base : `${base}/runs/${v.runId}`
+ }
const libraryCounts = () => {
const all = props.entries()
const live = all.filter((entry) => entry.live).length
@@ -624,9 +643,21 @@ export function WorkflowsScreen(props: ScreenProps) {
)}
- 60}>
- {libraryCounts()}
-
+
+ 60}>
+ {libraryCounts()}
+
+ 50}>
+
+ {"web "}
+
+ {webLink()!
+ .replace(/^https?:\/\//, "")
+ .replace(/\/runs\/([0-9a-f-]{8})[0-9a-f-]*$/, "/runs/$1…")}
+
+
+
+
{truncate(`! ${wf.error()}`, innerWidth())}
diff --git a/packages/plugin/test/gateway-auth.test.ts b/packages/plugin/test/gateway-auth.test.ts
new file mode 100644
index 0000000..dddf047
--- /dev/null
+++ b/packages/plugin/test/gateway-auth.test.ts
@@ -0,0 +1,60 @@
+import { afterAll, describe, expect, it } from "bun:test"
+import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+
+import { createTokenStore } from "../src/gateway/auth"
+
+const dir = await mkdtemp(path.join(os.tmpdir(), "wf-tokens-"))
+afterAll(() => rm(dir, { recursive: true, force: true }))
+
+describe("gateway tokens", () => {
+ it("deleting an entry from the token file revokes it in the running store", async () => {
+ const file = path.join(dir, "gateway-tokens.json")
+ const store = await createTokenStore(file)
+ const kept = await store.issue("kept", ["read"])
+ const removed = await store.issue("removed", ["read", "control"])
+ expect(store.verify(removed.token)?.name).toBe("removed")
+
+ const doc = JSON.parse(await readFile(file, "utf8")) as { tokens: Array<{ id: string }> }
+ doc.tokens = doc.tokens.filter((token) => token.id !== removed.id)
+ await writeFile(file, JSON.stringify(doc))
+
+ expect(store.verify(removed.token)).toBeNull()
+ expect(store.verify(kept.token)?.name).toBe("kept")
+ })
+
+ it("a file made unreadable (chmod 000) grants nothing, even for a token it held", async () => {
+ if (process.platform === "win32" || process.getuid?.() === 0) return
+ const file = path.join(dir, "locked.json")
+ const store = await createTokenStore(file)
+ const issued = await store.issue("device", ["read"])
+ expect(store.verify(issued.token)?.name).toBe("device")
+ await chmod(file, 0o000)
+ expect(store.verify(issued.token)).toBeNull()
+ await chmod(file, 0o600)
+ })
+
+ it("issuing after an outside edit keeps the edit (a revocation is not written back)", async () => {
+ const file = path.join(dir, "edited.json")
+ const store = await createTokenStore(file)
+ const revoked = await store.issue("revoked", ["read"])
+ await writeFile(file, JSON.stringify({ tokens: [] }))
+ await store.issue("new", ["read"])
+ const names = (JSON.parse(await readFile(file, "utf8")) as { tokens: Array<{ name: string }> }).tokens.map(
+ (t) => t.name,
+ )
+ expect(names).toEqual(["new"])
+ expect(store.verify(revoked.token)).toBeNull()
+ })
+
+ it("a missing or unreadable file grants nothing", async () => {
+ const file = path.join(dir, "broken.json")
+ const store = await createTokenStore(file)
+ const issued = await store.issue("device", ["read"])
+ await writeFile(file, "{ not json")
+ expect(store.verify(issued.token)).toBeNull()
+ await rm(file)
+ expect(store.verify(issued.token)).toBeNull()
+ })
+})
diff --git a/packages/plugin/test/live/harness.ts b/packages/plugin/test/live/harness.ts
index 094fd6f..c614fc0 100644
--- a/packages/plugin/test/live/harness.ts
+++ b/packages/plugin/test/live/harness.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -53,6 +53,26 @@ function freePort(): number {
return port
}
+/**
+ * A config directory for the test server: the machine's global config (providers, models) without its plugins,
+ * so a copy of this plugin installed on the machine (say from npm, with other options) never loads beside the
+ * one under test.
+ */
+async function isolatedConfig(root: string): Promise {
+ const home = path.join(root, "config")
+ const target = path.join(home, "opencode")
+ await mkdir(target, { recursive: true })
+ const global = path.join(
+ process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"),
+ "opencode",
+ "opencode.json",
+ )
+ const config = JSON.parse(await readFile(global, "utf8").catch(() => "{}")) as Record
+ delete config.plugins
+ await writeFile(path.join(target, "opencode.json"), JSON.stringify(config, null, 2))
+ return home
+}
+
export async function startLive(options: LiveOptions = {}): Promise {
const root = await mkdtemp(path.join(await realTmp(), "wf-live-"))
const project = path.join(root, "project")
@@ -80,6 +100,7 @@ export async function startLive(options: LiveOptions = {}): Promise
await mkdir(path.dirname(path.join(project, file)), { recursive: true })
await writeFile(path.join(project, file), contents)
}
+ const configHome = await isolatedConfig(root)
if (options.commit)
await Bun.$`git add -A && git -c user.email=live@test -c user.name=live commit -qm fixture`.cwd(project).quiet()
let output = ""
@@ -91,6 +112,7 @@ export async function startLive(options: LiveOptions = {}): Promise
...process.env,
OPENCODE_DB: path.join(root, "opencode.db"),
XDG_CACHE_HOME: path.join(root, "cache"),
+ XDG_CONFIG_HOME: configHome,
XDG_STATE_HOME: path.join(root, "state"),
},
stdout: "pipe",
diff --git a/packages/plugin/test/protocol-schemas.test.ts b/packages/plugin/test/protocol-schemas.test.ts
index 63eeb76..c9dcbcd 100644
--- a/packages/plugin/test/protocol-schemas.test.ts
+++ b/packages/plugin/test/protocol-schemas.test.ts
@@ -11,3 +11,16 @@ test("the published protocol JSON Schemas match the zod source", () => {
expect(result.stderr.toString()).toBe("")
expect(result.exitCode).toBe(0)
})
+
+test("the OpenAPI document is self-consistent: every $ref resolves and operation ids are unique", async () => {
+ const { openApiDocument } = await import("../src/protocol/openapi")
+ const doc = openApiDocument() as {
+ paths: Record>
+ components: { schemas: Record }
+ }
+ const refs = [...JSON.stringify(doc).matchAll(/"\$ref":"#\/components\/schemas\/([^"]+)"/g)].map((m) => m[1]!)
+ expect(refs.length).toBeGreaterThan(10)
+ for (const name of refs) expect(doc.components.schemas[name]).toBeDefined()
+ const ids = Object.values(doc.paths).flatMap((methods) => Object.values(methods).map((op) => op.operationId))
+ expect(new Set(ids).size).toBe(ids.length)
+})