@huynle/hub 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/README.md CHANGED
@@ -9,7 +9,18 @@ from your phone or any browser.
9
9
 
10
10
  Hub is harness-agnostic by design: the hub's API, web console and spoke protocol are not tied to one
11
11
  agent. [OpenCode](https://opencode.ai) is the first supported harness, through the `@huynle/hub` OpenCode
12
- plugin. More harnesses can connect by speaking the same spoke protocol (`src/protocol.ts`).
12
+ plugin; Claude Code, Codex and terminal agents described by a preset connect through spokes of their own
13
+ ([docs/harnesses.md](docs/harnesses.md)). More harnesses can connect by speaking the same spoke protocol
14
+ (`packages/protocol`), most easily on the spoke SDK ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
15
+
16
+ | Harness | Connect it with | Evidence | What the hub can do |
17
+ |---|---|---|---|
18
+ | OpenCode | `opencode plugin add @huynle/hub` | native | everything below |
19
+ | Claude Code | `hub claude [claude args…]` | hooks | follow, message, answer permissions, stop; launches |
20
+ | Codex | `hub codex [codex args…]` | rpc | follow, message, turns, steer, interrupt, answer approvals, stop; launches |
21
+ | Presets (Aider, goose, your own) | `hub preset run <name>` | screen | paste prompts and messages, Ctrl-C |
22
+
23
+ The full per-harness matrix, setup, limits and threat model: [docs/harnesses.md](docs/harnesses.md).
13
24
 
14
25
  Formerly **opencode-voice** and, briefly, **opencode-hub**
15
26
  ([what changed](#migrating-from-opencode-voice-or-opencode-hub)).
@@ -20,13 +31,35 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
20
31
  (tool calls, thinking, subagents, streamed replies), read the conversation, send a turn (it steers the
21
32
  run while the session is busy), stop a whole session tree, answer permissions, switch models, and
22
33
  start new sessions, from the phone.
23
- - **Notifications**: web push when a session asks a question, needs permission, fails or finishes, with
24
- per-device toggles, quiet hours and per-session mute.
34
+ - **Notifications**: web push when a session asks a question, needs permission, fails, or is **all done** (the
35
+ session and all its subagents and scripts settled, once per run), plus opt-in finer kinds (every turn,
36
+ stopped, retrying, each subagent, each script, long tools) and **Activity updates** (one quiet, self-replacing
37
+ notification per session); one toggle per kind and device, quiet hours and per-session mute
38
+ ([docs/notifications.md](docs/notifications.md)).
39
+ - **Live without waste**: the console's stream resumes where it left off after a lock or app switch (event
40
+ ids + replay), pauses while the page is hidden, and carries streamed text only for the session on screen.
41
+ - **Running detection**: a session counts as working while OpenCode reports it busy or retrying, or while a
42
+ shell or terminal it started still runs (e.g. a background `sleep 60 &` or build script), also when it was
43
+ already running before the plugin started.
25
44
  - **Voice** (optional feature, `/voice`): dictation and a hands-free conversation mode with a voice agent
26
45
  that answers in short spoken sentences on a fast model and hands real work to background subagents.
27
46
  Bring your own speech: OpenRouter or any OpenAI-compatible STT/TTS service (including self-hosted).
28
47
  - **What's open in your terminals first**: sessions open in Herdr or the OpenCode TUI, grouped like
29
- Herdr's sidebar, with the rest under Recent; pin and hide in sync across devices.
48
+ Herdr's sidebar, with the rest under Recent; rename, pin and archive in sync across devices.
49
+ - **Agent identity**: every session's shells carry its own hub identity, so an agent can run `hub status`, name
50
+ itself (`@scout`), claim a role (`@@reviewer`) and read other sessions, with app tokens for scripts and device
51
+ tokens for apps ([Agent identity](#agent-identity-agent-api)).
52
+ - **Agent messaging**: sessions, scripts and you message each other through the hub (`hub msg @reviewer …`,
53
+ `hub ask`, the OpenCode `hub` tool, `/msg`, `hub mcp`); asks to `@me` become Decisions you answer from the
54
+ console ([Agent messaging](#agent-messaging)).
55
+ - **Launches**: you, a script or an app start a session on one of your machines (`hub launch --branch feat/x
56
+ --prompt "…"`): a `hub node` daemon prepares a git worktree, the session gets its name, roles, labels and first
57
+ prompt, and ends by itself when it is done, with signed webhooks for apps ([docs/launches.md](docs/launches.md)).
58
+ - **More harnesses**: Claude Code (`hub claude`, through its hooks), Codex (`hub codex`, through its app-server)
59
+ and any terminal agent described by a data-only preset (`hub preset run`); the console shows only the controls
60
+ each harness's spoke serves, with an evidence badge and honest delivery labels
61
+ ([docs/harnesses.md](docs/harnesses.md)). Write a spoke for another harness on the spoke SDK and check it with the
62
+ conformance suite ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
30
63
  - **Session details**: context usage, todos, MCP status, branch and per-file diffs, on demand; unread
31
64
  marks synced per user.
32
65
  - **Self-hostable**: a hub on your computer with nothing extra to install, or your own hub for phones
@@ -34,10 +67,11 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
34
67
  several people.
35
68
 
36
69
  In a harness, `/hub` configures the harness's connection to the hub, and each feature is its own
37
- `/<command>`: today `/voice`; more features will be added the same way.
70
+ `/<command>`: today `/voice`, `/who` and `/msg`; more features will be added the same way.
38
71
 
39
- Requires OpenCode v2 (tested with 2.0.25) for the OpenCode harness. The hub runs on OpenCode's own
40
- runtime, a standalone binary, Docker, or [Bun](https://bun.sh) ≥ 1.3.
72
+ Requires OpenCode v2 (tested with 2.0.25) for the OpenCode harness; the Claude Code spoke is tested with Claude
73
+ Code 2.1.296 and the Codex spoke with Codex 0.160.0. The hub runs on OpenCode's own runtime, a standalone binary,
74
+ Docker, or [Bun](https://bun.sh) ≥ 1.3.
41
75
 
42
76
  ## Quick start (OpenCode)
43
77
 
@@ -62,6 +96,8 @@ Then, in OpenCode:
62
96
  |---|---|
63
97
  | **`/hub`** | Configures this harness's hub connection: the server URL, connect and approve (enroll) this machine, which sessions to share (mode), pair a phone, and open the hub in the browser. The first time it offers **Set up Hub** with two paths (below). |
64
98
  | **`/voice`** | The Voice feature: talks to the session on screen. It connects (running the setup first when needed), shares the session for Voice and opens it in the browser with Voice mode starting. |
99
+ | **`/who [project \| @@role]`** | Lists the hub's named and role-holding sessions (name, project, state, online, roles), asked as the session on screen. |
100
+ | **`/msg @name text`** | Sends the text word for word to another session (`@name`, `@@role`, `@name.project`, `ses_…`, or `@me` for you) as the session on screen. Needs a shared, connected session. |
65
101
 
66
102
  ### Local: everything on this computer
67
103
 
@@ -107,6 +143,7 @@ The hub serves the web app, pairs devices, enrolls machines, and does push and (
107
143
  | **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub/releases) |
108
144
  | **Docker** | Servers | `ghcr.io/huynle/hub` (linux/amd64, linux/arm64) |
109
145
  | **bunx / from source** | Bun users, development | `bunx --package @huynle/hub hub-server` · `bun bin/hub.ts` |
146
+ | **Kubernetes (cluster mode)** | Several pods on Postgres + Valkey, rolling updates | [docs/cluster.md](docs/cluster.md), manifests in [`deploy/k8s/`](deploy/k8s/) |
110
147
 
111
148
  Every way reads the same configuration (env vars over `hub.json`, see [Configuration](#configuration)) and
112
149
  answers `--help` and `--version`. While no device is paired, the hub logs a pairing code for the first one.
@@ -160,8 +197,8 @@ foreground with `hub.json`; `scripts/io.github.huynle.hub.plist` is a launchd te
160
197
  **Security:** the hub can drive your OpenCode sessions, which can run tools. Every API call needs a
161
198
  paired device or a signed-in user; spokes need machine tokens; nothing trusts the client IP. For a public
162
199
  hostname, consider [sign-in with Google](#multi-user-sign-in-with-google) or an authenticating proxy in
163
- front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, the manifest and icons reachable for
164
- spokes, sign-in and the PWA.
200
+ front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, `/.well-known/hub`, the manifest and
201
+ icons reachable for spokes, sign-in, app discovery and the PWA.
165
202
 
166
203
  ## Configuration
167
204
 
@@ -189,18 +226,549 @@ conversation mode and utterances (403 `not_exposed` for sessions not shared with
189
226
 
190
227
  | Route | Result |
191
228
  |---|---|
192
- | `POST /api/sessions/:id/turn {text, images?}` | sends a turn; while the session tree is running it steers instead. Answers `{mode: "turn" \| "steer"}`. `images` is up to 4 `{mime, data, name?}` (PNG, JPEG, WebP or GIF; canonical base64 whose bytes match the type; each at most 512 KB decoded, together at most 750,000 base64 characters); `text` may be empty when images are attached. They reach OpenCode as inline file attachments. 400 empty, over 100,000 characters or invalid images (with the reason); 409 "A turn is already running for this session" |
193
- | `GET /api/sessions/:id/timeline/messages/:message/files/:index` | one image attached to a user message, as bytes with its type (`cache-control: private, no-store`). Timeline user items list their attachments as `files: [{index, mime, name?}]` (never the bytes). Only PNG, JPEG, WebP and GIF are served (404 otherwise or when missing); 413 when the image would not fit in a protocol frame (about 650 KB) |
229
+ | `POST /api/sessions/:id/turn {text, images?}` | sends a turn; while the session tree is running it steers instead (202 `{mode: "steer", inboxID}`). An idle turn answers its `TurnResult` (`{outcome, reply, message?}`) when it ends; with `Prefer: respond-async` (the console sends it) a turn still running after 1.5 s answers 202 `{mode: "turn"}` (`Preference-Applied: respond-async`) and its result follows as an SSE `turn` event `{sessionID, outcome, reply, message?}`, so no proxy cuts a long request. The session stays busy until the turn ends either way. `images` is up to 4 `{mime, data, name?}` (PNG, JPEG, WebP or GIF; canonical base64 whose bytes match the type; each at most 512 KB decoded, together at most 750,000 base64 characters); `text` may be empty when images are attached. They reach OpenCode as inline file attachments. 400 empty, over 100,000 characters or invalid images (with the reason); 409 "A turn is already running for this session" |
230
+ | `GET /api/sessions/:id/timeline?limit=N&order=desc` · `?limit=N&before=<older>` | the conversation as timeline rows, newest first: the latest `limit` rows (1–200, default 50) in chronological order, plus `older` (the cursor of the rows before them) while there are more. `before=<older>` returns the page before that cursor. Cursors are row keys, so they stay valid while new messages arrive. A page starts at a message boundary when it can (it may be up to twice `limit` then). 400 for an unknown cursor (history reverted or compacted: start again from the newest page). The forward mode `?limit=N&cursor=` (oldest rows first, `cursor` for the next page) still works; it cannot be combined with `before` or `order=desc`. The spoke reads OpenCode's history for a first page and serves the following pages from a short-lived per-session cache (30 s, dropped when the session changes) |
231
+ | `GET /api/sessions/:id/timeline/messages/:message/files/:index` | one image attached to a user message, as bytes with its type (`cache-control: private, no-store`). Timeline user items list their attachments as `files: [{index, mime, name?}]` (never the bytes). Every timeline row carries `pos` (ordering position: session message order, then part order; derived from the message's creation time, kept increasing, so it does not change as pages move); tool rows `callID`, subagent tool rows `childSessionID` and `title` (the task description), assistant text rows `ordinal`. Only PNG, JPEG, WebP and GIF are served (404 otherwise or when missing); 413 when the image would not fit in a protocol frame (about 650 KB) |
232
+ | `POST /api/sessions/:id/rename` `{auto: true}` | Auto-name: OpenCode regenerates the title with its Title agent (`session.update` with an empty title on the process's own server). 200 `{title}` (or `{title, unchanged: true}` when OpenCode kept it), 202 `{pending: true}` when it is still generating after 20 s (the catalog updates when it lands), 409 `plugin_outdated` when no process reporting the session supports it, 502 when the process cannot reach its server or the title model failed |
233
+ | `POST /api/sessions/:id/rename` | `{title}` → `{title}`: renames the session in OpenCode (`session.update`), through the process that owns it, else any other process that reports it. The title is trimmed and must be 1–200 characters (400 otherwise). Works with Voice off; the new title shows in every console at once (the spoke reports it before answering) |
194
234
  | `POST /api/sessions/:id/stop` | interrupts running descendants deepest first, then the root; idempotent. Also releases the hub's turn lock for the tree: a turn request still waiting answers `{outcome: "interrupted"}` and the next turn can start (`/interrupt` does the same) |
195
235
  | `GET /api/sessions/:id/summary` | context usage, todos, MCP status (at most 100 servers), branch and working-tree change stats (each part optional). `changes` lists at most 500 files, most-changed first, with `totalFiles` and `truncated`; its `additions`/`deletions` cover every file |
196
236
  | `GET /api/sessions/:id/diff?file=` | one file's patch, on demand (capped at 400 KB as JSON, then `truncated: true`); never stored in the hub |
197
237
  | `POST /api/sessions/:id/read` · `GET /api/reads` | per-user read marks, synced to your other devices |
198
- | `GET /api/push/prefs?endpoint=` | `{prefs, quietHours, muted, subscriptions, subscribed?}`: this device's toggles (`handoff, question, permission, finished, failed, say`) and quiet hours, the user's muted sessions, how many subscriptions the hub holds for this device, and whether it holds `endpoint` |
199
- | `PUT /api/push/prefs {toggles…, quietHours?}` | boolean toggles (a 0.5.0 `blocked` sets question and permission); `quietHours` `{start: "HH:MM", end: "HH:MM", timeZone}` or `null` (400 when invalid) |
238
+ | `GET /api/events?lite=1&session=<id>&last=<id>` | the live stream (SSE). Every message has an `id:`; reconnecting with `Last-Event-ID` (EventSource does it) or `?last=` replays what was missed when the hub still has it (last 2000 events or 5 minutes per user; one fresh `catalog` stands for any catalog changes), else sends the catalog then `{type: "resync"}` (the page refetches what it shows). Ids are `<epoch>.<n>`; a hub restart starts a new epoch. `lite=1` leaves out `delta`/`reasoning` except of `session`'s tree (status, activity, catalog, turns and the rest still come). Heartbeat: `event: ping` every 20 s |
239
+ | `GET /api/push/prefs?endpoint=` | `{prefs, quietHours, activityMinutes, muted, subscriptions, subscribed?}`: this device's toggles (`handoff, question, permission, done, failed, turn, stopped, retry, subagentDone, subagentFailed, jobDone, jobFailed, longTool, say, activity`; `PUT` also accepts the legacy `finished` → `done` and `blocked` → question + permission), activity update interval and quiet hours, the user's muted sessions, how many subscriptions the hub holds for this device, and whether it holds `endpoint` |
240
+ | `PUT /api/push/prefs {toggles…, quietHours?, activityMinutes?}` | boolean toggles (a 0.5.0 `blocked` sets question and permission); `quietHours` `{start: "HH:MM", end: "HH:MM", timeZone}` or `null`; `activityMinutes` a whole number 1–60 (400 when invalid) |
200
241
  | `POST /api/push/subscribe {subscription, prefs?, oldEndpoint?}` · `POST /api/push/unsubscribe {endpoint}` | this device's subscriptions; `oldEndpoint` is dropped (rotated subscription). The endpoint must be https on a public hostname that resolves only to public addresses (no loopback, private, link-local, CGNAT or ULA hosts, no `localhost`/`*.local`/`*.internal`), else 400 |
201
- | `POST /api/push/test` | a test notification to the caller's device only: `{sent, failed: [{host, status?}], subscriptions}`; 429 with `retry-after` within 10 s of the last one |
242
+ | `POST /api/push/test {kind?}` | a test notification to the caller's device only, shaped like `kind` when given (400 for an unknown kind): `{sent, failed: [{host, status?}], subscriptions}`; 429 with `retry-after` within 10 s of the last one |
202
243
  | `PUT /api/push/mute {sessionID, muted}` | mutes or unmutes a session's notifications for the user: `{muted}` |
203
244
 
245
+ ## Agent identity (agent API)
246
+
247
+ Agents, scripts and apps talk to the hub through `/api/agent/*`, a small API with its own credentials, separate
248
+ from the console's routes. Every request resolves to exactly one **actor**:
249
+
250
+ | Actor | Credential | Who holds it |
251
+ |---|---|---|
252
+ | user | the device cookie (browsers) or the same secret as `Authorization: Bearer <device token>` (apps, CLIs; [linked with a code](#multi-user-sign-in-with-google)) | you, on one of your devices |
253
+ | session | a **session token** `hst1.…` the hub mints for each session a spoke reports | that session's shells (`HUB_SESSION_TOKEN`) |
254
+ | app | an **app token** `hat_…` you create (scopes `sessions:read`, `messages:send`, `messages:read`, `sessions:act`) | a script or tool outside a session (`hub login`) |
255
+
256
+ **Session tokens in shells.** The hub mints a 24 h token for every session (subagents included) its spokes report
257
+ for you and pushes it to the spoke, which keeps it in memory only. Every shell an agent starts through OpenCode's
258
+ shell tool gets that session's `HUB_SESSION_TOKEN` and `HUB_URL` (the spoke's hub), with the hub bin dir first on
259
+ PATH, so [`hub`](#hub-cli) inside a session acts as that session: `hub whoami` prints `session ses_… on machine
260
+ mch_…`. Tokens are renewed automatically before they expire; a shell started earlier keeps its old token, so a
261
+ `token_expired` answer means: start a new shell. A token stops working as soon as the session is no longer
262
+ reported by that machine, the machine is revoked or the user is disabled (see
263
+ [docs/multi-user.md](docs/multi-user.md#agent-actors-and-tokens)). Spokes of other harnesses get tokens only
264
+ when they announce the `sessionTokens` feature. Each session also has a separate **act token**, which never goes
265
+ into a shell; see [Agent actions and grants](#agent-actions-and-grants).
266
+
267
+ **Addressing.** Wherever a route or command takes a session, it takes an address, resolved among the caller's
268
+ own sessions only:
269
+
270
+ | Address | Means |
271
+ |---|---|
272
+ | `ses_…` | a session ID, or an unambiguous prefix of one |
273
+ | `@name` | a named root session. Names are `[a-z0-9-]`, 2–40 characters; each root session gets one from its first real title: its first few words, at most 4 and 24 characters, with ID-like tokens such as `ses_…` dropped (`-2`, `-3` on a clash; later title changes keep it) and can be renamed (`hub name @scout`); the old name stays an alias for 24 h. `me` is reserved (`@me` is always the user): a title like "Me" derives `me-2` |
274
+ | `@me` | the user (messages and Decisions; never a session) |
275
+ | `@@role` | the session(s) holding a role (`hub role claim reviewer`); `--exclusive` claims fail while another live holder exists |
276
+ | `@name.project`, `@@role.project` | scoped to a project (its name, else the folder name) |
277
+
278
+ An address several sessions match answers `ambiguous` with the candidates; another user's sessions, names and
279
+ roles do not exist (`not_found`). A name is released 7 days after its session is archived or deleted; a role
280
+ holder offline for 30 minutes loses the role. Some role names (such as `orchestrator…`) are reserved to the user.
281
+
282
+ In an OpenCode prompt, an address is a reference, not routing: the prompt still goes to the session you typed it
283
+ in, word for word, and the plugin attaches a short `hub-references.txt` note naming the session(s) it means (state,
284
+ machine, project, your open asks to it) so the agent can contact them with the `hub` tool. OpenCode's own `@file`
285
+ and `@agent` mentions win; unknown names add nothing; if the hub does not answer within 1.5 s the prompt goes on
286
+ without the note.
287
+
288
+ **Routes.** Errors are `{error: {code, message, hint?, …}}` with the codes the [CLI](#hub-cli) maps to exit
289
+ codes. Console routes refuse session and app tokens (403); `/api/agent` refuses a request carrying both the
290
+ cookie and a hub bearer token (400).
291
+
292
+ | Route | User | Session | App (`sessions:read`) | Result |
293
+ |---|---|---|---|---|
294
+ | `GET /api/agent/whoami` | ✓ | ✓ | ✓ (any scope) | `{actor, user}` |
295
+ | `GET /api/agent/status` | ✓ | ✓ | ✓ | machines → projects → sessions, each with name, roles, state, harness, activity and blockers, plus `attention` |
296
+ | `GET /api/agent/sessions?project=&state=&machine=&label=k=v` | ✓ | ✓ | ✓ | the same sessions as a flat list |
297
+ | `GET /api/agent/sessions/:addr` | ✓ | ✓ | ✓ | one session with full blockers and the spoke's summary (`unsupported` when its harness lacks it) |
298
+ | `GET /api/agent/sessions/:addr/log?n=20` | ✓ | ✓ | ✓ | newest timeline rows (1–200); `422 unsupported {feature, harness}` when the spoke has no timeline, `503 offline` |
299
+ | `GET /api/agent/watch?blocked=1&session=<addr>` | ✓ | ✓ | ✓ | SSE of compact events (`ready`, `state`, `blocker.opened/closed`, `session.added/removed`, `message`; no text) |
300
+ | `GET /api/agent/who?project=&role=` | ✓ | ✓ | ✓ | named, role-holding and online sessions |
301
+ | `POST /api/agent/name {name, session?}` | ✓ | own session | – | names a session (`409 name_taken`) |
302
+ | `POST /api/agent/roles/claim {role, exclusive?, project?, session?}` · `…/roles/release` | ✓ | own session | – | claims (with a new `generation`) or releases a role |
303
+ | `POST /api/agent/msg {to, text, wake?, urgent?, all?}` | ✓ | ✓ | `messages:send` | tells a session (`all`: every holder of a role); never yourself |
304
+ | `POST /api/agent/ask {to, text, expiresIn?, wait?}` | ✓ | ✓ | `messages:send` | an ask, open until answered, declined or expired; `wait` (≤ 60 s) returns the `answer` |
305
+ | `POST /api/agent/ask {to: "@me", text, options?, recommendation?, urgency?, tier?, deadline?, onTimeout?, about?, wait?}` | – | ✓ | `messages:send` | opens a **Decision** for the user (below): `{message, decision, answer?}` |
306
+ | `GET /api/agent/decisions?state=` · `…/decisions/:id` | ✓ (all) | own | `messages:read` (own) | the Decisions the caller asked |
307
+ | `POST /api/agent/reply {replyTo, text}` · `…/decline {replyTo, why}` | ✓ | ✓ | `messages:send` | answers a message you received (to its real sender); not a Decision's ask |
308
+ | `GET /api/agent/inbox` · `GET /api/agent/messages/:id?wait=` | ✓ | ✓ | `messages:read` | open asks, your waiting asks, recent messages, mutes · one message and its answer |
309
+ | `POST /api/agent/mute {session}` · `…/unmute` | ✓ | ✓ | `messages:send` | refuses (or allows again) messages from a session |
310
+ | `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
311
+ | `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
312
+
313
+ The act verbs, grants, kill switches and the agent audit are in [Agent actions and grants](#agent-actions-and-grants).
314
+
315
+ **Decisions and `@me`.** `@me` addresses the user: `msg @me` is stored in the user's inbox (push kind `message`);
316
+ `ask @me` opens a Decision `{id: "dec_…", from, about, question, options: [{id, label, safe?}], recommendation:
317
+ {option, why?}, urgency: low|normal|high, tier: confirm|tap, deadline, onTimeout, state, answer?}` (push kind
318
+ `decision`, generic body, opens `about[0]`, default the asking session). Only the user answers, on the console:
319
+ `GET /api/decisions?state=`, `GET /api/decisions/:id`, `POST /api/decisions/:id/answer {option, text?}` (`text` alone
320
+ for a Decision without options; same-origin cookie or a device token; `tap` Decisions only with the console
321
+ cookie, else `403 forbidden`). The first answer wins, a later one gets `409 {code: "gone"}`; the answer reaches the
322
+ asker as the user's reply to its ask (`Decision dec_…: Hold [hold]`). At the deadline, `onTimeout` (only an option
323
+ marked `safe`) answers it (`answeredBy: "timeout"`); without one the Decision stays open and is pushed again once.
324
+ `deadline` is in seconds and must fall before the ask expires (default: a day past the deadline, at most 7 days).
325
+ The console shows open Decisions as cards on Home and in the sessions they concern (SSE `{type: "decision",
326
+ decision}` on every open, answer, timeout, escalation and expiry); a `decision` push opens its card
327
+ (`?decision=<id>`).
328
+
329
+ Reads come from the hub's catalog; only `show` (summary) and `log` (timeline) ask the session's spoke, so they
330
+ depend on what its harness supports. A session token reads all of its user's sessions but names and claims roles
331
+ only for itself; an app token reads, and messages only with the `messages:*` scopes. `GET /.well-known/hub` (public) answers `{publicURL, version,
332
+ minClientVersion, auth, features}` for apps and CLIs.
333
+
334
+ **Apps.** A native app links with a code from Settings → Devices → **Add app** (`POST /api/devices/link`, then
335
+ `POST /api/devices/redeem` from the app; see [Multi-user](#multi-user-sign-in-with-google)) and uses the device
336
+ token as a user actor, on console routes and `/api/agent` alike. Revoking the device ends it.
337
+
338
+ ## Agent messaging
339
+
340
+ Sessions, scripts, apps and you can message each other through the hub. Everything goes through the
341
+ [`/api/agent` routes](#agent-identity-agent-api) above; the sender is always the credential that made the request
342
+ (a session token, an app token or your device), never a field of the request.
343
+
344
+ **Addresses.** `@name` (a session's name), `@@role` (the session holding a role: the idle holder, else the most
345
+ recently active one; `--all` sends to every holder), `@name.project` / `@@role.project`, `ses_…`, and `@me` for
346
+ you. Addresses resolve among your own sessions only; another user's sessions do not exist (`not_found`).
347
+
348
+ **Verbs.** The same verbs exist in the CLI (`hub …`), the OpenCode `hub` tool, `hub mcp` and the SDK:
349
+
350
+ | Verb | What it does |
351
+ |---|---|
352
+ | `msg <addr> "text"` | tells a session something (fire and forget) |
353
+ | `ask <addr> "question"` | an ask, open until answered, declined or expired (1 minute to 7 days, default 24 h); `--wait` blocks for the answer |
354
+ | `reply <msgID> "text"` · `decline <msgID> --why "…"` | answers a message you received; it goes to the message's real sender, never through the role |
355
+ | `inbox` | asks waiting for your answer, your own open asks, recent messages, mutes |
356
+ | `mute <addr>` · `unmute <addr>` | refuses (or accepts again) messages from a session (`forbidden`, exit 5) |
357
+ | `who` | named and role-holding sessions |
358
+
359
+ `--no-wake` admits a message without starting a turn and `--urgent` steers it into a running turn. Texts are at
360
+ most 10,000 characters (a decline's reason 1,000).
361
+
362
+ **Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
363
+ OpenCode's synthetic message; it shows in the console's conversation as a message row). An idle session gets a
364
+ turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it; subagent sessions and
365
+ `--no-wake` messages wait for the next turn. A session that is offline gets it when it comes back (pending, for 24
366
+ hours). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
367
+ `unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
368
+ after waits of 5, 15 and 45 minutes in turn, then you are notified. Messages to you and to apps are stored in an inbox (`hub inbox`).
369
+
370
+ **Envelope.** The recipient sees the message framed so its body cannot pose as anything else:
371
+
372
+ ```
373
+ [hub] ask from @api (laptop) to @@reviewer → you · 10-24 08:15Z · msg_7f3a…
374
+ ----- msg_7f3a… -----
375
+ please review the login fix
376
+ ----- msg_7f3a… -----
377
+ ↩ hub reply msg_7f3a… "…" · hub decline msg_7f3a… --why "…" · expires 10-25 08:15Z
378
+ ```
379
+
380
+ The boundary carries the message's own id; body lines that look like a header, a boundary or a reply hint are
381
+ escaped with a leading backslash; control characters, ANSI escapes, bidi overrides and zero-width characters are
382
+ removed from the text and from every interpolated name.
383
+
384
+ **Primer.** Once per session, the first time it is named explicitly or gets its first message, the hub adds a short
385
+ note without starting a turn: who the session is (`@name`, its roles), how to message others (`hub msg`, `hub who`),
386
+ and that messages headed `[hub]` come from other sessions or apps, not from the user. When that first message
387
+ wakes an idle session, the note and the message reach the model together, in one model call.
388
+
389
+ **Decisions.** `ask @me` from a session or app opens a Decision (question, options, a recommendation, urgency, a
390
+ deadline with an optional safe `onTimeout`). You answer it from the console (cards on Home and in the sessions it
391
+ concerns, a `decision` push); the answer goes back to the asker as your reply. Only users answer Decisions, never
392
+ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the routes.
393
+
394
+ **Where agents send from.**
395
+ - In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
396
+ - The model itself: the plugin's `hub` tool (verbs `msg`, `ask`, `reply`, `decline`, `inbox`, `who`, `status`,
397
+ `whoami`, and the [act verbs](#agent-actions-and-grants)), as the calling session ([Spoke](#spoke-opencode-plugin)).
398
+ - You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
399
+ - Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with.
400
+ - Scripts: `hub login` with an app token that has the `messages:send` (and `messages:read`) scopes.
401
+
402
+ **Limits.** Per 10 minutes: 20 messages per sender→recipient pair and 300 per user (`rate_limited`, exit 6, with
403
+ `retryAfter`), and 30 turns started per recipient (more messages are still delivered, without waking it). A chain of
404
+ replies between agents may run 8 hops without a human turn; past that, messages are delivered without waking and
405
+ you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. You can switch
406
+ messaging off for your account (`PUT /api/agent/messaging {enabled: false}`): sends then answer `paused` (exit 5),
407
+ reading still works.
408
+
409
+ ## Agent actions and grants
410
+
411
+ Agents can do more than message: a session or a script may **approve** or **deny** a permission request, **answer**
412
+ a form, **prompt** a session or **stop** it, in your sessions and only as far as you allow. You always may (your
413
+ own sessions, any of your credentials); an agent acts only under a **grant** you created, and anything outside it
414
+ comes back to you as a Decision.
415
+
416
+ **Who can act.**
417
+
418
+ | Credential | Acts? |
419
+ |---|---|
420
+ | you (device cookie, device token) | always, on your own sessions; no grant, no kill switch |
421
+ | a session's **act token** (held by the OpenCode `hub` tool) | under a grant held by that session (by its ID, its `@name`, or a reserved `@@orchestrator…` role it holds) |
422
+ | an app token with scope `sessions:act` | under a grant held by `app:<token id>` |
423
+ | a session's **shell token** (`HUB_SESSION_TOKEN`) | never: `403 forbidden` |
424
+
425
+ **Act tokens vs shell tokens.** Every session has two hub tokens. The shell token goes into the shells the session
426
+ starts, so `hub msg` and the other read and message verbs work from any script it runs. The act token (`typ act`)
427
+ is pushed only to spokes that announce `actTokens`, kept apart from the shell tokens, never put in any
428
+ environment and never shown in tool output; only the plugin's `hub` tool uses it, for the act verbs. A command an
429
+ agent runs in a shell therefore cannot act, even with the session's own credential; the model has to call the tool,
430
+ where the call is visible and audited.
431
+
432
+ **Grants.** A grant says who may do what, where, until when:
433
+
434
+ ```json
435
+ {
436
+ "holder": "@@orchestrator",
437
+ "scope": { "projects": ["/home/you/projects/api"] },
438
+ "allow": ["approve", "deny", "prompt", "stop"],
439
+ "permissions": { "allow": ["shell:bun test*", "edit:src/*"], "never": ["shell:git push*"] },
440
+ "expires": "8h",
441
+ "maxActionsPerHour": 60,
442
+ "allowAlways": false
443
+ }
444
+ ```
445
+
446
+ - **holder**: `ses_…` or `@name` (pinned to that session when the grant is created; the grant never follows the name
447
+ to another session), a reserved `@@orchestrator…` role (follows whoever holds it when it is used; only you assign
448
+ reserved roles), or `app:<token id>` (one of your app tokens).
449
+ - **scope.projects**: 1–20 absolute project directories; a session is in scope when its directory is one of them or
450
+ inside one (on a path boundary, after resolving `.`/`..`; worktrees inside the project count).
451
+ - **allow**: the verbs (`approve`, `deny`, `answer`, `prompt`, `stop`; `roles` is accepted for role management).
452
+ `deny`, `answer`, `prompt` and `stop` need only the verb; `approve` also needs the request to pass the patterns.
453
+ - **permissions**: patterns `action[:glob]` in OpenCode's wildcard syntax (`*` any run, `?` one character, a trailing
454
+ ` *` optional; `bash` is OpenCode's `shell`; the action alone means every resource). A request is allowed only
455
+ when **every** resource it names matches an allow pattern; any never pattern wins over every allow. In shell allow
456
+ patterns, `$(`, backticks and `<`/`>` must be written literally (a wildcard never matches them), so
457
+ `shell:bun test*` does not allow `bun test $(rm -rf ~)`. Never patterns also see the command without leading
458
+ `VAR=…` assignments and wrappers (`sudo`, `env`, `command`, `exec`, `nohup`, `time`), so `sudo git push` is still
459
+ a `git push`.
460
+ - **expires**: 1 minute to 30 days (seconds, or `30m`, `8h`, `2d`; default 8 h). **maxActionsPerHour**: 1–1000
461
+ (default 60), per clock hour; past it `429 rate_limited`. **allowAlways**: lets the holder reply `always` (OpenCode
462
+ then saves the request's rules), and only when every rule it would save lies inside the allow patterns.
463
+ - At most 50 active grants per user. Editing replaces a grant (its holder stays, its expiry restarts); revoking ends
464
+ it at once and keeps it listed for the audit.
465
+
466
+ **hub never rules.** `agents.never` in [hub.json](#hubjson) lists patterns no agent may approve for any user,
467
+ checked before every grant (`{"agents": {"never": ["shell:git push*", "shell:rm -rf*", "external_directory"]}}`).
468
+ The user can still approve such a request themselves.
469
+
470
+ **The check order** for an agent: the session belongs to you (else `not_found`, like a session that does not
471
+ exist) → kill switches → a usable grant for the verb and the session's project → not its own session tree (a
472
+ session never answers a request or form in its own tree) → the hub's never rules → the grant's never rules → the
473
+ allow patterns (else `needs_user`) → the hourly cap → the kill switches again → the spoke.
474
+
475
+ | Answer | HTTP | CLI exit | Means |
476
+ |---|---|---|---|
477
+ | ok | 200 | 0 | done (the result names the grant used) |
478
+ | `needs_user` | 403 | 4 | outside the grant: the user decides (a Decision is open) |
479
+ | `forbidden` | 403 | 5 | no grant, a never rule, its own tree, or a shell token |
480
+ | `paused` | 423 | 5 | a kill switch is on |
481
+ | `rate_limited` | 429 | 6 | the grant's hourly cap |
482
+ | `not_found` | 404 | 3 | not your session, or no such pending request or form |
483
+ | `gone` | 410 | 8 | answered elsewhere first, expired or ended |
484
+ | `unsupported` | 422 | 7 | the session's spoke cannot do it (e.g. an agent's prompt without `hubPrompt`) |
485
+ | `offline` | 503 | 7 | no spoke reports the session now |
486
+
487
+ **Escalation.** An approval outside the grant answers `needs_user` with the evaluation (`verdict`, `reason`, the
488
+ grants checked, and a `tier`) and opens a Decision for you: one per agent and request (asking again updates its
489
+ reason), with Approve once (or Approve always, when asked), Deny (safe), and the agent's `--why` as its
490
+ recommendation. The hub sets the tier, never the agent: `tap` (answered by a tap in the console, not from a
491
+ notification) for shell, `external_directory`, MCP and unknown tools and for anything near a never rule; `confirm`
492
+ otherwise. Your answer replies to the permission **as you** (no grant or kill switch applies to you); if the request
493
+ was answered elsewhere or ended first, the Decision closes and the agent's next try is `not_found`. A request's
494
+ blocker in the console shows the recommendation ("@orchestrator recommends Approve once: …"). Limits: 20 new
495
+ escalations per agent an hour and 20 open at once per user; escalations within a minute of the last push open
496
+ quietly.
497
+
498
+ **Grant requests.** Agents never create grants. A session (any of its tokens) or an app token with `messages:send`
499
+ may **request** one: `POST /api/agent/grants/requests {grant, why}` (`hub grant request …`). The holder defaults to
500
+ the requester and may only be the requester itself or a reserved role it holds. It opens a tap Decision summarising
501
+ the grant; approving creates it as yours, and the requester is told the grant id in a reply. One open request per
502
+ requester, 5 an hour.
503
+
504
+ **Prompts.** An agent's prompt reaches the session as the hub's, never as yours: the spoke wraps it in a provenance
505
+ envelope (`[hub] prompt from @lead · grant g_… · msg_…`, the body between boundary lines) and admits it as a hub
506
+ message, so it never resets the loop protection's hop chain the way your own typing does. Agents may prompt root
507
+ sessions only, never themselves, text only, always waking the session (`--delivery queue|steer` picks how it enters
508
+ a busy one).
509
+
510
+ **Deny reasons.** `deny --why "…"` passes the reason to the harness as the rejection's feedback (prefixed with who
511
+ denied it and under which grant), on spokes that announce `permissionMessage`; an older spoke takes the reject
512
+ without it, and the answer says so (`reasonSent: false`).
513
+
514
+ **Kill switches.** Settings → Agents (or `PUT /api/agent/acting {enabled: false}`) turns agent actions off for you;
515
+ an admin's `PUT /api/admin/agents/acting {enabled: false}` turns them off for the whole hub. Either one answers
516
+ every act verb `423 paused`; reading and messaging keep working (messaging has its own switch), and your own
517
+ actions and Decision answers still go through.
518
+
519
+ **Verbs.** The same act verbs are in the [CLI](#hub-cli) (`hub approve | deny | answer | prompt | stop`, `hub grant
520
+ list | show | create | revoke | request`), the OpenCode `hub` tool (verbs `approve`, `deny`, `answer`, `prompt`,
521
+ `stop`, with the session's act token), [`hub mcp`](#hub-mcp-mcp-server) (tools of the same names, as the app token)
522
+ and the SDK. Only you manage grants: the console (Settings → Agents) or the CLI with your device token
523
+ (`hub login --token <device token>`).
524
+
525
+ | Route | User | Session (act token) | App (`sessions:act`) |
526
+ |---|---|---|---|
527
+ | `POST /api/agent/sessions/:addr/approve {requestID, always?, why?}` | ✓ | under a grant | under a grant |
528
+ | `POST /api/agent/sessions/:addr/deny {requestID, why?}` | ✓ | under a grant | under a grant |
529
+ | `POST /api/agent/sessions/:addr/answer {formID, answer \| cancel: true, why?}` | ✓ | under a grant | under a grant |
530
+ | `POST /api/agent/sessions/:addr/prompt {text, delivery?, why?}` (users: `images?`) | ✓ | under a grant | under a grant |
531
+ | `POST /api/agent/sessions/:addr/stop {why?}` | ✓ | under a grant | under a grant |
532
+ | `POST /api/agent/grants` · `GET` · `GET`/`PUT`/`DELETE …/grants/:id` | ✓ | – | – |
533
+ | `POST /api/agent/grants/requests {grant, why}` | – | ✓ (any token) | `messages:send` |
534
+ | `GET` · `PUT /api/agent/acting {enabled}` | ✓ | – | – |
535
+ | `GET` · `PUT /api/admin/agents/acting {enabled}` | admins | – | – |
536
+ | `GET /api/agent/audit?limit=&before=` | ✓ | – | – |
537
+
538
+ **Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`) is in
539
+ your agent audit with who acted, the verb, the session and request, the grant, the reason and the outcome (`ok`,
540
+ `gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`); so are your answers to escalations, grant
541
+ requests and switch changes. Read it in Settings → Agents → Activity or with `GET /api/agent/audit` (newest first,
542
+ pages of up to 500; the last 2000 entries are kept); new entries stream to your console as SSE `{type: "agent",
543
+ entry}`. Admins see only a summary in the hub audit (the user, `agent.<verb>` and the actor kind; grant changes as
544
+ `grant.create`, `grant.update`, `grant.revoke` with the grant id), never sessions, requests, reasons, holders or
545
+ patterns.
546
+
547
+ ## Launches
548
+
549
+ A **launch** starts a session for you on one of your machines and ends it when it is done. On each machine that
550
+ should take launches, enrol it (the OpenCode plugin's `/hub` dialog), list where its repositories live in
551
+ `~/.config/hub/node.json` and run the node daemon:
552
+
553
+ ```sh
554
+ echo '{"workspaceRoots": ["/Users/you/projects"]}' > ~/.config/hub/node.json
555
+ hub node install # writes the launchd agent / systemd user unit and prints how to load it
556
+ ```
557
+
558
+ Then, from anywhere:
559
+
560
+ ```sh
561
+ hub launch --repo ~/projects/app --branch feat/search --name search --prompt "Add full-text search" --cleanup remove
562
+ hub launches list # queued → placing → provisioning → running → ended (idle, stopped, …)
563
+ ```
564
+
565
+ The node makes the git worktree `~/projects/app/.worktrees/feat/search`, the machine's OpenCode service starts the
566
+ session there as `@search`, the hub primes and prompts it, and once the session has worked and stays idle the
567
+ launch ends `idle` and the worktree is removed (the branch stays; nothing uncommitted is ever removed). Apps launch
568
+ with an app token (scope `sessions:launch`) under a grant with verb `launch`, retry safely with a `key`, tag
569
+ launches with labels and follow them with signed webhooks. `--harness claude-code` or `--harness codex` launches
570
+ those harnesses through their service spokes ([docs/launches.md](docs/launches.md#harnesses)). Everything, from the
571
+ spec's fields and limits to placement, caps, the node's git rules, webhook signatures and sandboxed launches in
572
+ OpenShell (`hub node openshell check`): [docs/launches.md](docs/launches.md).
573
+
574
+ ## hub CLI
575
+
576
+ `hub` (also installed as `hubctl`) reads, names, messages and acts on your sessions through the hub's [`/api/agent` API](#agent-identity-agent-api),
577
+ for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`$HUB_STATE_DIR/bin`, default
578
+ `~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
579
+ session `hub` just works. A launcher runs the CLI bundle shipped in this package (`packages/cli/dist/hub-cli.js`)
580
+ with the OpenCode binary itself (`BUN_BE_BUN=1 exec <opencode> hub-cli.js "$@"`), so neither bun nor node has to
581
+ be installed; without Bun it falls back to `node`. The launchers are rewritten only when their content changes.
582
+
583
+ **Name clash:** GitHub's old `hub` CLI (github/hub) uses the same name. In hub sessions ours comes first on PATH;
584
+ elsewhere use `hubctl`, or call the bundle directly (`bun …/packages/cli/dist/hub-cli.js`).
585
+
586
+ ```
587
+ hub status [--json] machines → projects → sessions, and what needs attention
588
+ hub sessions [--project P] [--state S[,S]] [--machine M] [--label k=v]
589
+ hub show <addr> state, blockers, activity, summary
590
+ hub log <addr> [-n 20] newest timeline rows (1–200), printed oldest first
591
+ hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pings skipped)
592
+ hub whoami who this credential is
593
+ hub who [--project P] [--role R | @@role] named and role-holding sessions
594
+ hub name <@name> [--session <addr>]
595
+ hub role claim <role[.project]> [--exclusive] [--session <addr>]
596
+ hub role release <role[.project]> [--session <addr>]
597
+ hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
598
+ hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--all] [--no-wake] [--urgent]
599
+ hub reply <msgID> "text" [--no-wake] to the message's real sender, never via the role
600
+ hub decline <msgID> --why "reason"
601
+ hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
602
+ [--deadline 2h [--on-timeout <safe id>]] [--tier confirm|tap] [--about <addr>]... a Decision for the user
603
+ hub decisions list [--state open|answered|expired|closed] the Decisions you asked
604
+ hub inbox asks to answer, your open asks, recent messages, mutes
605
+ hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
606
+ hub approve <addr> <requestID> [--always] [--why "…"] answer a permission request (once, or always)
607
+ hub deny <addr> <requestID> [--why "…"] reject it; the reason reaches the harness
608
+ hub answer <addr> <formID> key=value… | --json '{…}' | --json - | --cancel [--why "…"]
609
+ hub prompt <addr> "text" [--delivery queue|steer] [--why "…"]
610
+ hub stop <addr> [--why "…"] stop its running turn
611
+ hub grant list · hub grant show <id> · hub grant revoke <id>
612
+ hub grant create --holder <h> --project <path>... --allow approve,deny,… [--permit <pattern>]... [--never <pattern>]...
613
+ [--expires 8h] [--max-per-hour N] [--allow-always] | --json '{…}'
614
+ hub grant request --project <path>... --allow approve,deny,… [--permission-allow <pattern>]... [--never <pattern>]...
615
+ [--holder <@@role you hold>] --why "…" (agents) ask the user for a grant
616
+ hub launch [--repo <path>] [--mode worktree|checkout|clone] [--branch B] [--base B] [--remote URL]
617
+ [--name <@name>] [--role R]... [--harness H] [--agent A] [--model provider/model]
618
+ [--runtime host|openshell [--profile P]] [--machine M] [--node-label k=v]... [--prefer]
619
+ [--label k=v]... [--prompt "text" | --prompt -] [--end idle|asked|never] [--timeout 4h]
620
+ [--cleanup keep|remove] [--key K] start a session on a hub node
621
+ hub launches list [--state S[,S]] [--label k=v]... · hub launches show <id> · hub launches stop <id>
622
+ hub node run the node daemon on this machine, until Ctrl-C or SIGTERM
623
+ hub node install [--dry-run] · hub node uninstall [--dry-run] · hub node status
624
+ hub claude [claude args…] Claude Code with the hub plugin (--plugin-dir; ~/.claude untouched)
625
+ hub claude-code install|uninstall [--dry-run] · hub claude-code status the Claude Code daemon as a service
626
+ hub codex [codex args…] Codex with its shared app-server daemon on (~/.codex untouched)
627
+ hub codex-spoke install|uninstall [--dry-run] · hub codex-spoke status the Codex spoke as a service
628
+ hub preset list · hub preset check <name|file>
629
+ hub preset run <name|file> [--socket <name> | --current] [--cwd <dir>] [--resume <id>] [--no-hub] [-- args…]
630
+ hub mcp an MCP server on stdio (see below)
631
+ hub login [--url URL] [--token APP_TOKEN] · hub logout
632
+ hub help · hub --version
633
+ ```
634
+
635
+ Addresses are `ses_…` (or an unambiguous prefix), `@name`, `@@role`, `@name.project` and `@me` (the user). Output is short text
636
+ (one session per line: name and ID, state, harness, current activity, blockers); `--json` prints the hub's answer
637
+ unchanged (for errors, the `{error: {code, message, hint?, …}}` envelope on stdout as well). Errors print the
638
+ message, any candidates or retry time, a missing feature, and the hint on stderr.
639
+
640
+ **Messaging.** `msg` and `ask` print what was stored and how it reached the recipient (`delivered` into a new or
641
+ the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline, or
642
+ `stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
643
+ `--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
644
+ read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
645
+ `--wait` take seconds or `30s`, `5m`, `2h`, `1d` (asks expire after 1 minute to 7 days, default 24 h). `ask --wait N`
646
+ waits up to N seconds for the answer (the CLI loops on the hub's 60 s long poll); a bare `--wait` waits until the
647
+ ask expires. A waiting `ask` exits 0 with the answer, 1 when declined (with the reason) or still open when the
648
+ wait is spent, and 8 when it expired; `--wait` cannot be combined with `--all`. `hub inbox` shows each open ask
649
+ with its `hub reply` / `hub decline` line. A recipient whose spoke cannot receive messages exits 7 (`unsupported`,
650
+ feature `deliver`: update the plugin there), messaging switched off by the user exits 5 (`paused`), sending too
651
+ much exits 6 (`rate_limited`), and answering an ask that is already answered or expired exits 8 (`gone`). Sessions
652
+ message with their session token; an app token needs the `messages:send` (and `messages:read` for `inbox`) scopes.
653
+
654
+ **Acting.** `approve`, `deny`, `answer`, `prompt` and `stop` act on another session: the user's credential always
655
+ may (its own sessions); an app token needs scope `sessions:act` **and** a grant; a session's shell token
656
+ (`HUB_SESSION_TOKEN`) never acts — agents act through the OpenCode `hub` tool, which holds the session's separate
657
+ act token. Outside the grant the hub answers `needs_user`: the CLI exits 4 and prints the evaluation (verdict,
658
+ tier, reason, the grants checked) and the Decision opened for the user, if any. `answer` takes `key=value` pairs (a
659
+ repeated key becomes a list), or the whole answer as JSON (`--json '{…}'`, or `--json -` from stdin; a bare
660
+ `--json` still means raw output), or `--cancel`. `--why` is recorded in the agent audit; for `deny` it is passed to
661
+ the harness. An already-answered request is `gone` (8), an offline machine `offline` (7).
662
+
663
+ **Grants.** `hub grant list | show | create | revoke` manage what a holder (`ses_…`, `@name` — pinned to that
664
+ session at creation —, a reserved `@@orchestrator…` role, or `app:<token id>`) may do: which verbs,
665
+ in which projects, which permission patterns (`--permit`, `--never`; never wins), for how long and how often. Only
666
+ the user manages grants (the console, or `hub login` with a device token): from a session's shell `create` and
667
+ `revoke` exit 5 without asking the hub, and any credential the hub refuses exits 5. Agents `hub grant request` one
668
+ instead (a session, or an app token with `messages:send`): the same flags, the holder defaulting to the requester,
669
+ and a `--why` the user reads; it prints the Decision it opened, and the user's answer comes back as a reply. See
670
+ [Agent actions and grants](#agent-actions-and-grants).
671
+
672
+ **Launching.** `hub launch` starts a session on a machine running `hub node` ([docs/launches.md](docs/launches.md)): the flags become the
673
+ body of `POST /api/agent/launches`, with only the given fields (the hub fills in its defaults: harness `opencode`,
674
+ mode `worktree` — which needs `--branch` and uses `<repo>/.worktrees/<branch>` —, runtime `host`, end `idle`,
675
+ cleanup `keep`). `--repo` defaults to the working directory and relative paths resolve against it; the path is the
676
+ repository on the node and must be under that node's workspace roots. `--label k=v` tags the launch for apps
677
+ (`hub launches list --label brain.task=abc` finds it again); `--node-label k=v` and `--machine` pick nodes, required
678
+ unless `--prefer`. `--prompt -` reads the first prompt from stdin. `--key` makes retries safe: the same key and
679
+ flags answer the existing launch (`… was already launched with key …`), the same key with other flags is invalid
680
+ (exit 2). It prints the launch and its state (`queued`, `placing`, `provisioning`, `running` with the session,
681
+ `ended` with an outcome: `idle`, `needs_user`, `failed`, `timeout`, `stopped`, `lost`); `hub launches show` adds the
682
+ workspace, steps and artifacts (branch, head, commits). `hub launches stop` ends a launch (`stopped`); one that
683
+ already ended exits 8 (`gone`). The user may always launch; an app token needs scope `sessions:launch` **and** a
684
+ grant with verb `launch` covering the repo (exit 5 otherwise; the kill switch pauses it); sessions launch only
685
+ through the OpenCode `hub` tool's act token. Apps and sessions see and stop only the launches they started.
686
+ Too many launches exit 6 with the retry time. Apps that register a webhook verify its `Hub-Signature` with the
687
+ SDK's `verifyWebhook({body, headers, secret})` (HMAC-SHA256 of `<t>.<raw body>`, compared in constant time,
688
+ timestamps older than 5 minutes refused). The spec's fields and limits, states, placement, caps and webhooks are in
689
+ [docs/launches.md](docs/launches.md).
690
+
691
+ **hub node.** `hub node run` is the per-machine daemon that prepares launch workspaces: it needs an enrolled
692
+ machine (it only reads the machine token in `~/.config/hub/config.json`) and `~/.config/hub/node.json` with at
693
+ least `{"workspaceRoots": ["/Users/you/projects"]}` (optional `labels`, `slots`, `memoryMB`); it exits 1 when the
694
+ machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. `hub node install` writes a
695
+ launchd agent (macOS, `~/Library/LaunchAgents/io.github.huynle.hub-node.plist`) or a systemd user unit (Linux,
696
+ `~/.config/systemd/user/hub-node.service`) from the templates in `scripts/`, with the runtime and CLI entry running
697
+ the command (so `hubctl node install` from a hub session runs the bundle with the OpenCode binary) and
698
+ `HUB_SPOKE_CONFIG` / `XDG_CONFIG_HOME` when set, and **prints** the `launchctl` / `systemctl` commands that load it;
699
+ hub never runs them. `--dry-run` prints the file without writing it; a second install says it is up to date (or
700
+ updates it), and warns when the node would not run yet. `hub node uninstall` removes the file and prints the
701
+ commands that stop the service; `hub node status` shows the file (up to date or not), the enrolment (never the
702
+ token), node.json and the commands to inspect the service. What the node does to your repositories (worktrees under
703
+ `<repo>/.worktrees/<branch>`, cleanup that never forces) is in [docs/launches.md](docs/launches.md#hub-node).
704
+
705
+ **Other harnesses.** `hub claude` runs Claude Code with the hub's plugin loaded through `--plugin-dir` and starts the
706
+ per-user Claude Code daemon (`hub claude-code daemon`) when none runs; `hub codex` runs Codex with its shared
707
+ app-server daemon turned on for that run and starts the per-user Codex spoke (`hub codex-spoke serve`). Both pass
708
+ every other argument and the exit code through, never write `~/.claude` or `~/.codex`, and run the harness without
709
+ the hub (saying why) when the machine is not enrolled. `hub claude-code install` and `hub codex-spoke install` write
710
+ a launchd agent or systemd user unit for those processes, like `hub node install` (printed, never loaded), so
711
+ `claude-code` and `codex` launches can be placed without a wrapper run. `hub preset list | check | run` drives a
712
+ harness without a spoke through a pane on a private tmux server. Settings, the op matrix and the limits:
713
+ [docs/harnesses.md](docs/harnesses.md).
714
+
715
+ **Credentials.** `HUB_URL` + `HUB_SESSION_TOKEN` (the spoke sets them in each session's shells; the CLI then
716
+ acts as that session) win over the login file. Outside a session, `hub login --url https://hub.example` stores
717
+ an app token in `~/.config/hub/cli.json` (`$XDG_CONFIG_HOME` respected; mode 0600) after checking it with
718
+ `whoami`; the token is read from stdin (or prompted) unless `--token` is given — prefer stdin, since arguments are
719
+ visible to other local users in `ps`. App tokens are created by a signed-in user with
720
+ `POST /api/agent/app-tokens {label?, scopes?}` (scopes `sessions:read`, `messages:send`, `messages:read`; shown once) and revoked with
721
+ `DELETE /api/agent/app-tokens/:id`; `hub logout` only deletes the file. App tokens can read but not name or
722
+ claim roles (writes are for sessions and users). `whoami` and `status` warn on stderr when `OPENCODE_SESSION_ID`
723
+ is set and the credential is not that session. The CLI removes `BUN_BE_BUN` from its own environment.
724
+
725
+ | Exit | When |
726
+ |---|---|
727
+ | 0 | ok |
728
+ | 1 | other errors, network failures, a watch stream the hub closed, an `ask --wait` declined or not yet answered |
729
+ | 2 | usage (unknown command or option, missing argument; also the hub's `invalid`) |
730
+ | 3 | `not_found`, `ambiguous` (candidates listed), `name_taken` |
731
+ | 4 | `needs_user` |
732
+ | 5 | `forbidden`, `paused` |
733
+ | 6 | `rate_limited` (with `retryAfter`) |
734
+ | 7 | `unsupported`, `offline` |
735
+ | 8 | `gone`; an `ask --wait` that expired |
736
+ | 9 | `unauthorized`, `token_expired` (a session token: start a new shell), or not logged in |
737
+
738
+ ### hub mcp (MCP server)
739
+
740
+ `hub mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio for MCP clients outside
741
+ OpenCode (Claude Desktop, Cursor, other agents). It speaks JSON-RPC 2.0, one message per line (MCP's stdio
742
+ transport; protocol revisions `2025-06-18`, `2025-03-26` and `2024-11-05`), and answers `initialize`, `ping`,
743
+ `tools/list` and `tools/call`; notifications (`notifications/initialized`, `notifications/cancelled`, which aborts
744
+ the call) get no answer, other methods answer `-32601`. Its tools are the CLI's verbs: `whoami`, `status`,
745
+ `sessions`, `show`, `log`, `who`, `msg`, `ask`, `reply`, `decline`, `inbox` and the act verbs `approve`, `deny`,
746
+ `answer` (`answer` object or `cancel`), `prompt` (`delivery`) and `stop`, and the launch tools `launch` (`repo`
747
+ required and absolute; `mode`, `branch`, `base`, `remote`, `key`, `name`, `roles`, `harness`, `agent`, `model`,
748
+ `runtime`, `profile`, `machine`, `nodeLabels` and `labels` objects, `prefer`, `prompt`, `end`, `timeout`,
749
+ `cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`), with the same arguments
750
+ (`to`, `text`, `addr`, `id`, `requestID`, `formID`, `why`, `n`, `wake`, `urgent`, `all`, `project`, `role`, …). The act
751
+ tools need an app token with scope `sessions:act` and a grant, the launch tools scope `sessions:launch` and a
752
+ launch grant; `needs_user` results include the evaluation. `ask` takes `wait` of at
753
+ most 60 s (one long poll; a longer wait is cut to 60) and `expires` in seconds or as `2h`. Answers are the CLI's
754
+ short text; messages and answers from other sessions come in the hub's envelope and are marked as not from the
755
+ user. Hub errors are tool results with `isError: true` and the hub's `code: message` (plus candidates and hint),
756
+ never protocol errors. Only protocol frames go to stdout; the server ends when stdin closes (after answering the
757
+ calls in flight) or on SIGINT/SIGTERM.
758
+
759
+ ```json
760
+ { "mcpServers": { "hub": { "command": "/home/you/.local/state/hub/bin/hubctl", "args": ["mcp"] } } }
761
+ ```
762
+
763
+ (the launcher in the hub bin dir, `$HUB_STATE_DIR/bin`; just `hubctl` when that dir is on the client's PATH).
764
+
765
+ **Identity.** The server acts as the CLI's credential, read for each call: `HUB_URL` + `HUB_SESSION_TOKEN` when
766
+ set, else the `hub login` file. MCP clients start servers without a hub session's shell environment, so in practice
767
+ it acts as the **app token** you logged in with (needs `sessions:read`, `messages:send` and `messages:read`), not
768
+ as an OpenCode session: messages are sent from the app, and replies to them land in its inbox (the `inbox` tool),
769
+ not in a session. Naming and roles stay out (app tokens cannot use them). Inside OpenCode, agents use the plugin's
770
+ `hub` tool instead, which acts as the calling session.
771
+
204
772
  ## Spoke (OpenCode plugin)
205
773
 
206
774
  The plugin is a thin client ("spoke") of the hub. Every OpenCode process that loads
@@ -208,6 +776,13 @@ it opens **one** outbound WebSocket (`/spoke/ws`, protocol in `src/protocol.ts`)
208
776
  knows, forwards their events, and runs turns, stops and permission replies on the hub's request.
209
777
  The web app, speech and phones live on the hub.
210
778
 
779
+ Besides the session, tool and permission events, a spoke forwards `session.status` (busy/retry/idle) and the
780
+ shells and terminals sessions start (`session.shell.started/ended`, `shell.created/exited/deleted`,
781
+ `pty.created/updated/exited/deleted`) in compact form: id, session, command (≤ 200 characters), status and exit
782
+ code; never output, files or environment. When it connects (and again after its hub connection or OpenCode
783
+ event stream comes back) it asks its own server what runs now (`session.active` and `shell.list`) and sends a
784
+ `session.seed` event per running session, so a session that was already running shows as working at once.
785
+
211
786
  | Entry | File | Role |
212
787
  |---|---|---|
213
788
  | server | `server.ts` → `src/index.ts` | Starts the process-wide spoke (`src/spoke/`), `hub.*` RPC (also under the legacy id `voice.*`), the `voice` agent, short-reply and orchestrator `context` hook |
@@ -297,7 +872,9 @@ work to a background subagent. Routine workers inherit the fast voice model; cod
297
872
  architecture and risky work use the configured full work model. Successful workers finish with a short
298
873
  speech-ready result that the hub reads directly, without waiting for another voice-agent summary. You
299
874
  can keep talking while work runs; related follow-ups continue the active worker and unrelated tasks start
300
- a fresh one.
875
+ a fresh one. Several independent requests in one breath ("check the deploy and update the changelog") start
876
+ one background worker each, in parallel, in the same turn; each worker gets a short name ("Deploy check") that
877
+ the task board and announcements use.
301
878
 
302
879
  The phone composer exposes separate **Voice** and **Worker** model controls for each session. Each picker
303
880
  lists the connected OpenCode process's available models and variants. Choices are saved per user and
@@ -305,7 +882,11 @@ session, apply to the next turn or newly started worker, and fall back to the ma
305
882
  `workModel` when unset.
306
883
 
307
884
  The conversation is an inline execution timeline: streamed thinking, tools and nested subagents appear
308
- between messages as compact expandable rows. Reopening a session pages its projected message history from
885
+ between messages as compact expandable rows. Rows keep the session's order, never wall-clock order: history
886
+ rows carry the spoke's `pos` (message order, then part order), live rows go after the history loaded when they
887
+ arrived (in arrival order) and are replaced in place by their stored row, and a subagent row stands where the
888
+ tool call that spawned it is (timeline tool rows of `subagent`/`task` carry `childSessionID`; a subagent whose
889
+ spawn is not loaded yet shows at the top of the loaded history). Reopening a session pages its projected message history from
309
890
  OpenCode. Expanding a tool fetches its complete input/output from the owning OpenCode process; the hub does
310
891
  not persist those potentially sensitive details.
311
892
 
@@ -320,8 +901,31 @@ not persist those potentially sensitive details.
320
901
  agent exists, the session keeps its agent and the `context` hook adds the orchestrator prompt instead.
321
902
  - The delegate agent (`subagent` in the config) is added per request by the `context` hook, so changing
322
903
  it needs no restart.
904
+ - **Task board**: the `context` hook also gives the voice agent (and a conversation on another agent) a live
905
+ list of the workers it started (`src/spoke/taskboard.ts`, from this process's events): title, sessionID,
906
+ state (working, blocked with what it waits for, done with its latest text, failed with the reason, stopped)
907
+ and for how long, at most 8 (live ones first) of at most 200 characters each. So "what's running?" or "how's
908
+ the deploy going?" needs no tool call. A session without workers gets no board.
909
+ - **Stopping one task by voice**: the plugin adds a tool `hub_task_stop {sessionID} | {all: true}`. The voice
910
+ agent calls it for "cancel the deploy one" (that worker and whatever runs below it are interrupted, deepest
911
+ first) or "stop all the workers" (every running worker of the session). It only reaches sessions below the
912
+ calling session (never the caller itself, its siblings or another session's workers), so the voice agent
913
+ keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
914
+ interrupt the root turn at once.
915
+ - **Messaging other sessions**: the plugin adds a tool `hub {verb, …}` with the verbs `msg`, `ask`, `reply`,
916
+ `decline`, `inbox`, `who`, `status` and `whoami` (`src/spoke/hub-tool.ts`). It acts as the calling session
917
+ with that session's own hub token (the token never appears in the tool's output). `ask` with `wait` (seconds,
918
+ at most 600) blocks for the answer until the tool call is stopped; without it the answer arrives later as a hub
919
+ message. Messages from other sessions are shown in the hub's envelope and are not from the user.
323
920
  - Child text and execution events are paired by the hub. The latest assistant message of a successful
324
- child is emitted as root-session speech; failures, interruptions and stale partial results are not.
921
+ child is emitted as root-session speech; interruptions and stale partial results are not. **Announcements**
922
+ (hub, `src/central/registry.ts`): while another worker of the session runs or waits, or one was announced in
923
+ the last minute, a result starts with the task's title ("Deploy check finished: …"); a result the voice agent
924
+ already said in the last two minutes is not read again. A failed worker is announced once with its reason
925
+ ("Lint failed: rate limit exceeded."), a worker's permission request or question once per request ("Cleanup
926
+ needs your permission.", "… has a question."); a stopped worker is not. Announcements are ordinary `say`s
927
+ without `seq` (`messageID` `announce:failed:<session>`, `announce:permission:<id>`, `announce:form:<id>`),
928
+ so the phone queues them after the sentence playing and never cuts one off.
325
929
  - Server ops: `steer` sends the text with `delivery: "steer"` (metadata `{source: "hub", mode:
326
930
  "conversation"}`, or `mode: "remote"` for a session not shared with Voice; 0.5 wrote `source:
327
931
  "opencode-hub"` and 0.4 and earlier `source: "opencode-voice"`, and readers accept all three) and returns the inbox ID at once; `interrupt`
@@ -348,7 +952,10 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
348
952
  | `HUB_AUTH` | `pairing` | `oidc` for several users signing in with Google; see [Multi-user](#multi-user-sign-in-with-google) |
349
953
  | `HUB_HOST` / `HUB_PORT` | `127.0.0.1` / `4097` | listen address |
350
954
  | `HUB_PUBLIC_URL` | `http://<host>:<port>` (`http://127.0.0.1:<port>` when the host is `0.0.0.0` / `::`) | origin used in links, cookies and CSRF checks |
351
- | `HUB_STATE_DIR` | `~/.local/state/hub` | `state.json` (0600): users, machines and devices (token hashes only) with their owners, VAPID keys, push subscriptions, speech settings (server default and per user; provider API keys set from the phone), the audit log (last 500 entries) |
955
+ | `HUB_STATE_DIR` | `~/.local/state/hub` | `state.json` (0600): users, machines and devices (token hashes only) with their owners, VAPID keys, push subscriptions, speech settings (server default and per user; provider API keys set from the phone), the audit log (last 500 entries); `token-keys.json` (0600): the HMAC keys that sign session tokens (created on first use, older keys kept so their tokens still verify); `agent/` (0700, files 0600, created on first use): agent data such as messages and name claims, one JSON file per user and collection, written shortly after each change and on shutdown |
956
+ | `HUB_TOKEN_KEYS` | | overrides `token-keys.json` with the same JSON, `{"active": "<kid>", "keys": [{"kid": "<kid>", "secret": "<base64 of ≥ 32 random bytes>", "created": <ms>}]}`, e.g. a secret shared by several hub instances; the file is then never written |
957
+ | `HUB_DATABASE_URL` / `HUB_VALKEY_URL` | | cluster mode: several hub pods sharing Postgres and Valkey (both or neither; requires `HUB_AUTH=oidc` and `HUB_TOKEN_KEYS`); see [docs/cluster.md](docs/cluster.md) |
958
+ | `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
352
959
  | `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
353
960
  | `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
354
961
  | `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
@@ -374,7 +981,29 @@ the Docker image, npm package and binaries ship the build). One store fed by the
374
981
  - **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
375
982
  terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
376
983
  title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
377
- (pin, hide, stop, Allow/Disallow Voice).
984
+ (rename, pin, archive, stop, Allow/Disallow Voice).
985
+ - **Archive**: **Archive** in a session's `⋯` menu takes it out of the table, the sidebar's groups, Recents and
986
+ Running, on all your devices (with **Undo**). Nothing is deleted and OpenCode's history is untouched:
987
+ archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive
988
+ will map to it). The **Archived** chip on `/` lists archived sessions with **Unarchive**. One exception: an
989
+ archived session that needs you (a permission or question) also shows under **Needs you** (sidebar and the
990
+ Needs you chip), so a question is never missed. In the table, tick rows (or the header box for all shown
991
+ rows) and press **Archive** (or **Unarchive** under Archived); **Archive inactive…** archives every session
992
+ without activity for more than N days (30 by default; not running, pinned or open in a terminal) after a
993
+ confirmation with the count. Sessions archived in OpenCode itself (`time.archived`) never reach the hub.
994
+ Sessions hidden with an earlier version are archived now (same stored list, `hiddenSessions`).
995
+ - **Rename**: **Rename…** in a session's `⋯` menu (table row, sidebar row, phone list, the session's title
996
+ menu), or a click on the title in the session's top bar, turns the title into a text field with the title
997
+ selected: Enter (or clicking away) saves, Escape cancels. The new title shows at once and is set in
998
+ OpenCode itself (`POST /api/sessions/:id/rename`), so terminals see it too; if that fails the old title
999
+ comes back with a message.
1000
+ - **Auto-name**: **Auto-name** in the same `⋯` menus, or the ✨ button next to the rename field, has OpenCode
1001
+ name the session with its Title agent (what `/rename` without a title does in the terminal; the model is
1002
+ `agents.title.model`), from the first request and the recent conversation. The title shows "Naming…" until
1003
+ OpenCode answers, then a toast says "Renamed to …" or "OpenCode kept the title". Needs this plugin version
1004
+ in the session's OpenCode process (older ones answer "Update the Hub plugin to use Auto-name") and a process
1005
+ that can reach its own server: the background service, or `opencode serve --port …` with
1006
+ `OPENCODE_SERVER_PASSWORD` (a TUI's own `serve --stdio` server cannot; use `/rename` there).
378
1007
  - **`/`**: All sessions as a sortable, filterable table (a list on phones); filters and sort are kept per device.
379
1008
  There is no Voice column or Voice filter: the live Voice session gets the green dot before its title, and the
380
1009
  row's `⋯` menu has **Allow Voice** / **Disallow Voice**.
@@ -440,8 +1069,8 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
440
1069
  group: New session here, Pin / Unpin workspace, Hide / Unhide workspace (and Collapse / Expand in
441
1070
  Recent). Hiding a workspace hides the sessions in it: a Herdr workspace (`herdr:<id>`), a project
442
1071
  folder, or a nested folder such as a worktree.
443
- - **Synced view settings**: pins and hides are per user on the hub (`GET`/`PUT /api/prefs/view`), shown at
444
- once, reverted with a message if the hub refuses, with Undo after a hide. Other devices update live
1072
+ - **Synced view settings**: pins, archived sessions and hidden workspaces are per user on the hub (`GET`/`PUT /api/prefs/view`), shown at
1073
+ once, reverted with a message if the hub refuses, with Undo after an archive. Other devices update live
445
1074
  (SSE `prefs`). Pins from 0.3 (stored on the device) are moved into them once. With an older hub
446
1075
  without `/api/prefs/view`, they stay on the device. Open/closed groups and the filter stay per device.
447
1076
  - **Voice off** sessions (`exposed: false`, mode `session`) open, show their status and live activity, and
@@ -461,16 +1090,40 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
461
1090
  failed, the session still exists and the hub's `warning` is shown. 409 (background service not
462
1091
  connected) is shown under the composer.
463
1092
 
464
- **Notifications** (web push, VAPID keys generated once and stored in the state file). Each device picks what
465
- it wants (Settings → Notifications): **a session asks a question**, **needs permission**, **failed**,
466
- **finished** (a root session's run succeeded, except voice turns started from the page), **hand-offs from the
467
- terminal**, and **Voice replies when you're not watching** (pref `say`: spoken replies while no device shows
468
- the session). Questions, permissions, hand-offs and Voice replies are on by default; finished and failed are
469
- opt-in (0.5.0's single "blocked" toggle became question + permission, and its "finished" became finished +
470
- failed, migrated on start). Optional **quiet hours** per device (local start/end time in the browser's time
1093
+ **Notifications** (web push, VAPID keys generated once and stored in the state file). Each device picks each
1094
+ kind it wants (Settings → Notifications, grouped, with a one-line description each and a "You'll get: …"
1095
+ summary); [docs/notifications.md](docs/notifications.md) lists every kind, its trigger and what is not notified:
1096
+
1097
+ - **Needs you**: a **question**, a **permission** (on by default).
1098
+ - **Session**: **All done** (on): the root session's last turn succeeded and the whole tree is settled (no
1099
+ subagent or shell job running, nothing waiting for an answer, no hub turn) for 15 s, once per run;
1100
+ **Failed** (on, with the reason); **Turn finished** (every root turn, 0.7's "finished": off, noisy because
1101
+ each background subagent that completes starts a short continuation turn); **Stopped** (shutdown,
1102
+ inactivity) and **Retrying** (provider retries), off.
1103
+ - **Subagents**: each subagent **finished** / **failed** (off). **Scripts and tools**: a background shell or
1104
+ terminal **exited 0** / **failed** (non-zero, killed, timed out), a **long tool** (≥ 30 s) ended (off).
1105
+ - **Voice**: spoken replies while no device shows the session (`say`) and **hand-offs** from the terminal (on).
1106
+ - **Activity digest** (off, below).
1107
+
1108
+ Titles read "*session* · *kind*" ("KVM Rewrite · All done", "KVM Rewrite · Subagent S3 macros finished",
1109
+ "KVM Rewrite · Script exited 1"). Nothing but questions, permissions, hand-offs and spoken replies is sent
1110
+ while a device shows the session. Stored 0.7 prefs migrate on start: `finished` becomes All done (not every
1111
+ turn), the rest is kept and the new kinds start off (0.5.0's single "blocked" toggle still becomes question +
1112
+ permission). Optional **quiet hours** per device (local start/end time in the browser's time
471
1113
  zone) hold everything except hand-offs; **Mute notifications** in a session's ⋯ menu silences one session
472
1114
  for you on every device (hand-offs still come through). At most one per session and kind every 30 s
473
- (Voice replies: 10 s); expired subscriptions (404/410) are pruned.
1115
+ (Voice replies: 10 s; subagents, scripts and long tools: per subagent, job or call); expired subscriptions
1116
+ (404/410) are pruned.
1117
+
1118
+ **Activity updates** (pref `activity`, off by default on every device; Settings → Notifications shows a hint
1119
+ until it is on): to know what happens without keeping the screen on. Steps of interest in a session tree — a
1120
+ subagent started or ended, a tool that took 30 s or more, a background shell or terminal that ended (with its
1121
+ exit code: "bun run build exited 0", "was killed", "timed out"), the session going idle — become one
1122
+ notification per session, tagged per session so each replaces the last quietly (no new sound, normal
1123
+ urgency, 5-minute TTL). The first step after a quiet spell goes out at once; later ones are collected until the
1124
+ device's interval ends (Settings: 1–60 minutes, default 5) and sent together ("3 tools · explore finished ·
1125
+ Finished"). Short tools are only counted. Quiet hours and muted sessions apply; nothing is sent while a
1126
+ device shows the session.
474
1127
 
475
1128
  - **Status per device**: Settings → Notifications shows the browser permission, whether the service worker
476
1129
  is registered and whether this hub holds this browser's subscription. "Allowed, but this device is not
@@ -478,12 +1131,14 @@ for you on every device (hand-offs still come through). At most one per session
478
1131
  notifications on this device** fixes it. When permission is granted, the app re-subscribes by itself on
479
1132
  load if the hub lost the subscription, and the service worker re-subscribes when the browser rotates it
480
1133
  (`pushsubscriptionchange`). **Send test notification** sends one to this device only and shows the result
481
- (sent, or the push service's status).
1134
+ (sent, or the push service's status); pick a kind next to it to see how that kind looks.
482
1135
  - **Delivery**: questions, permissions and failures are sent with `urgency: high` and a TTL of one hour so a
483
1136
  phone in doze gets them; they stay on screen until handled (`requireInteraction`), vibrate, and replace the
484
1137
  previous one of the same session and kind. A permission notification asks the hub (with the device's
485
1138
  cookie, from the service worker) what the request is, shows it, and offers **Allow once** / **Reject**;
486
- the payload itself never carries it. Payloads are `{kind, sessionID, title, body, requestID?}`.
1139
+ the payload itself never carries it. Payloads are `{kind, sessionID, title, body, requestID?, test?}`.
1140
+ Failures (and failed subagents and scripts) vibrate; progress kinds (turn, retrying, subagent and script
1141
+ finished, long tool, activity) replace the previous one of the session quietly.
487
1142
  - **Android (Chrome)**, for notifications that arrive with the screen off: open the public URL, sign in or
488
1143
  pair, then Chrome's menu → **Install app** (or Add to Home screen) and use the installed app; Settings →
489
1144
  Notifications → **Enable notifications on this device** and **Send test notification**. In Android
@@ -562,6 +1217,16 @@ disconnects their machines at once.
562
1217
  login links from `/hub` belong to the machine's owner. The bootstrap pairing code and the local
563
1218
  first-machine auto-approve exist only in `pairing` mode.
564
1219
 
1220
+ **Native apps** link from Settings → Devices → **Add app**: the dialog shows a QR code of
1221
+ `hubapp://link?hub=<public URL>&code=<code>` (and the link itself), with a 5-minute countdown. The code is
1222
+ single use, 256-bit, belongs to the user who opened the dialog (who must still be active when it is redeemed)
1223
+ and works only for linking: `/login` and `/api/pair` refuse it. The app calls `POST /api/devices/redeem
1224
+ {code, deviceName?, publicKey?}` (no cookie, and no `Origin` needed: the code is the only credential) and gets
1225
+ `{device, token}`; it keeps `token` and sends it as `Authorization: Bearer <device token>`. The device appears
1226
+ in the list (audit `device.pair`, "app link"); revoking it invalidates the token. `deviceName` is cleaned
1227
+ (control characters dropped, at most 64 characters, "App" when empty); `publicKey` (PEM or base64, at most
1228
+ 4096 characters) is stored with the device for later strong approvals.
1229
+
565
1230
  **Speech**: each user can set their own provider, models, voice and key (Settings → Speech); fields they
566
1231
  leave empty come from the server default (admins: Settings → Speech → Server default), then `hub.json`,
567
1232
  then the env. A user's key is only ever shown (masked) to that user.
@@ -601,8 +1266,8 @@ unchanged sessions frame does not re-broadcast the catalog.
601
1266
  |---|---|---|
602
1267
  | `POST /api/sessions/:id/expose {on}` | `expose` | `200 {exposed}` from a reporting process (service first). 400 bad body, 404 not reported, 502 spoke error |
603
1268
  | `POST /api/machines/:machineID/sessions {directory, title?, herdrWorkspace?}` | `create` | `201 {sessionID, openedIn?: "herdr", warning?}` from the machine's service process; with `herdrWorkspace` (≤ 100 characters) the session is also opened in a new tab of that Herdr workspace, and when that fails the answer has a `warning` instead of `openedIn`. 400 bad input or `unknown workspace` (not a workspace root, session directory or Herdr pane folder of that machine), 404 unknown machine, 409 no service process connected |
604
- | `GET /api/prefs/view` | – | `{prefs: {hiddenSessions, hiddenWorkspaces, pinnedSessions, pinnedWorkspaces}}`, the caller's view preferences (pairing mode: the single user) |
605
- | `PUT /api/prefs/view {any of the four lists}` | – | Replaces the lists given (each ≤ 500 non-empty strings of ≤ 4096 characters, duplicates dropped; other keys 400). Stored on the user record; SSE `{type: "prefs", prefs}` goes to the user's other devices (never to other users, not to the device that changed them) |
1269
+ | `GET /api/prefs/view` | – | `{prefs: {hiddenSessions, hiddenWorkspaces, pinnedSessions, pinnedWorkspaces}}`, the caller's view preferences (pairing mode: the single user). `hiddenSessions` is the user's **archived** sessions (the name is from before Archive was called Hide) |
1270
+ | `PUT /api/prefs/view {any of the four lists}` | – | Replaces the lists given (each ≤ 500 non-empty strings, `hiddenSessions` ≤ 5000, of ≤ 4096 characters, duplicates dropped; other keys 400). Stored on the user record; SSE `{type: "prefs", prefs}` goes to the user's other devices (never to other users, not to the device that changed them) |
606
1271
 
607
1272
  For a session reported only with `exposed: false`, `utterance` and `conversation` answer
608
1273
  `403 {error: "Voice is not enabled for this session", code: "not_exposed"}`; inspecting and controlling it
@@ -613,7 +1278,12 @@ session.
613
1278
 
614
1279
  ### Hub API
615
1280
 
616
- All routes need the device cookie; POSTs also need a same-origin `Origin` (or `Referer`). Every
1281
+ All routes need the device cookie; POSTs also need a same-origin `Origin` (or `Referer`). Native apps and
1282
+ other non-browser clients send the same device secret as `Authorization: Bearer <device token>` instead: same
1283
+ powers, no same-origin check (a request with both a cookie and a bearer token is refused with
1284
+ `400 {"error": …, "code": "invalid"}`; an unknown bearer token is `401 {"code": "unauthorized"}`). Other bearer
1285
+ tokens (session and app tokens) are refused on these routes (`403 {"code": "forbidden"}`); they are for
1286
+ `/api/agent/*` only. Every
617
1287
  `/api/sessions/:id…` route answers `404 {"error": "Not found"}` for a session that none of the caller's
618
1288
  machines reports (since 0.3.0; before, a session nobody reported was 409), so another user's session looks
619
1289
  exactly like an unknown one. For a loopback
@@ -635,21 +1305,39 @@ hub sends it `{op: "conversation", on: true}` once. Deleting the session clears
635
1305
 
636
1306
  SSE (`/api/events`) adds:
637
1307
 
1308
+ - Each session's status tree entry may carry `jobs` (`[{id, kind: "shell" | "pty", command?, status:
1309
+ "running" | "exited" | "timeout" | "killed", exit?, background?, started?, ended?}]`: running ones plus the
1310
+ last 5 that ended) and `retry` (`{attempt?, message?}`, with `detail` "Retrying: …"). A session is
1311
+ `working` while its run, a busy/retry `session.status` or a running job lasts. The console shows a job count
1312
+ next to the status (table, sidebar), "Retrying …" or "· 1 job" in the session header, and a **Jobs** card in
1313
+ the rail (command, running time, exit code for a minute after it ends).
1314
+
638
1315
  - `{type: "conversation", sessionID, on}` when the flag changes.
639
- - `{type: "say", sessionID, messageID, ordinal, text}` for each `session.text.ended` of a **root** session
640
- in conversation mode (subagent text is not spoken); deduped by `(messageID, ordinal)` across
641
- processes; blank text is skipped.
1316
+ - `{type: "say", sessionID, messageID, ordinal, seq, text}`: **root** text in conversation mode is spoken
1317
+ while it is written. The hub cuts the streamed `session.text.delta`s of each text part (`messageID`,
1318
+ `ordinal`) into sentences (`src/central/say-stream.ts`: sentence ends followed by the next sentence, line
1319
+ breaks, whole fenced code blocks, abbreviations/decimals/file names kept together, runs over 300 chars cut
1320
+ at a comma) and sends one `say` per completed sentence right after its delta, `seq` counting from 0 per text
1321
+ part; `session.text.ended` (authoritative: missed deltas are recovered) flushes the rest. Sentences are
1322
+ raw slices of the text: `/api/speak` makes each one speakable. One process's deltas are used per text part
1323
+ and the end is handled once, so each sentence is sent once; an interrupted or failed run, or conversation
1324
+ mode turning off, drops the unspoken rest. A successful subagent's final message is spoken through its
1325
+ root as before: one `say` per text part without `seq`. Blank text is skipped.
642
1326
  - `{type: "heard", sessionID, inboxID}` when OpenCode delivers an utterance's inbox item (also when the
643
1327
  delivery event arrives before the steer result).
644
1328
  - `{type: "activity", sessionID, rootID, item}` when an activity item is created or changes state, for
645
1329
  every session (all of the owner's devices see it, like `status`). `item` is `{id, sessionID, kind: "tool" | "thinking",
646
- tool?, summary?, background?, state: "running" | "done" | "failed", error?, at}`; `sessionID` is where it
1330
+ tool?, summary?, background?, childSessionID?, state: "running" | "done" | "failed", error?, at}`
1331
+ (`childSessionID`: the session a subagent tool started, once the tool reports it); `sessionID` is where it
647
1332
  happened (a subagent session maps to its root `rootID`). The hub keeps the 30 most recent items per
648
1333
  root (in memory) and returns them, newest last, as `activity` in `GET /api/sessions/:id`. Spokes forward
649
1334
  tool and reasoning events in compact form (`compactActivity`): tool name, a one-line summary of the
650
- input (command, path, pattern…), ok/error; never file contents or outputs. Thinking items close on
1335
+ input (command, path, pattern…), ok/error; never file contents or outputs. A subagent tool's
1336
+ `session.tool.progress` goes out only as `session.tool.called {id, childSessionID}` (a type every hub
1337
+ accepts); other progress is dropped. Thinking items close on
651
1338
  `session.reasoning.ended`, when the next activity in that session starts, or when its run ends.
652
- - `{type: "delta", sessionID, rootID, messageID?, text}` (assistant text) and `{type: "reasoning", sessionID,
1339
+ - `{type: "delta", sessionID, rootID, messageID?, ordinal?, text}` (assistant text; `ordinal`: which text part of
1340
+ the message, as OpenCode numbers them) and `{type: "reasoning", sessionID,
653
1341
  rootID, messageID?, delta}` (reasoning text; spokes cut each delta to 2000 chars and never forward the full
654
1342
  text of `session.reasoning.ended`) for root **and** subagent sessions. Per session they are coalesced to at
655
1343
  most one send per 100 ms (the first delta goes out at once, later ones in the window are joined); anything
@@ -657,11 +1345,13 @@ SSE (`/api/events`) adds:
657
1345
  - `status` entries of working sessions carry `since` (ms epoch the current run started), also in the catalog
658
1346
  and `GET /api/sessions/:id`.
659
1347
 
660
- Pushes in conversation mode depend on presence: while the session is watched, "finished" is not sent
661
- (the phone is open and speaking). When nobody watches, "finished" is sent as usual and each spoken text
662
- of the root session is pushed as kind `say` (title = session title, body = the text, max 180 chars; at
663
- most one every 10 s per session; on by default, pref `say`). "blocked" is always sent. `/api/transcribe` and `/api/speak` have no rate limit; `/api/speak` accepts up to
664
- `HUB_MAX_SPEECH_CHARS` per call, so the phone sends sentence-sized chunks.
1348
+ Pushes depend on presence: while the session is watched, run notifications (all done, turn, failed, …) are
1349
+ not sent (the page is open, and in conversation mode speaking). When nobody watches, they are sent as usual and in conversation mode each spoken text
1350
+ of the root session (the whole text part, when it ends) is pushed as kind `say` (title = session title, body = the text, max 180 chars; at
1351
+ most one every 10 s per session; on by default, pref `say`). Questions and permissions are always sent. `/api/transcribe` and `/api/speak` have no rate limit; `/api/speak` accepts up to
1352
+ `HUB_MAX_SPEECH_CHARS` per call, so the phone sends sentence-sized chunks. `/api/speak` streams the provider's MP3 through as it
1353
+ arrives (chunked; nothing is buffered on the hub, and a phone that cancels aborts the upstream request), so playback
1354
+ can start before synthesis ends; provider errors still answer 502 before any audio.
665
1355
 
666
1356
  ### Speech settings
667
1357
 
@@ -671,8 +1361,8 @@ STT and TTS each use a provider configured from the phone (Settings → Speech)
671
1361
  provider than `hub.json`, the file's section is ignored for that direction; when it picks another base
672
1362
  URL, the file's key is not sent there. Providers:
673
1363
 
674
- - `openrouter` (default, base `https://openrouter.ai/api/v1`): STT is JSON with base64 `input_audio`,
675
- TTS is `POST /audio/speech`.
1364
+ - `openrouter` (default, base `https://openrouter.ai/api/v1`): STT is an OpenAI-style multipart upload
1365
+ (`file`, `model`, `language?`: the audio goes up as binary, not base64 JSON), TTS is `POST /audio/speech`.
676
1366
  - `openai-compatible` (`baseURL` required: `https://…`, or `http://localhost` / `127.0.0.1` for local
677
1367
  servers): STT is multipart `POST {baseURL}/audio/transcriptions` (`file`, `model`, `language?`), TTS is
678
1368
  `POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}`. No env key.
@@ -691,27 +1381,62 @@ Writes need the same-origin `Origin`. Provider failures are 502 with the provide
691
1381
  ### Conversation mode (phone)
692
1382
 
693
1383
  On `/s/<id>`, the **Conversation** button next to the mic starts hands-free mode. It turns on the mic
694
- (echo cancellation, noise suppression and auto gain on), then calls `POST …/conversation {on:true}`, keeps
1384
+ (mono, with the browser's echo cancellation, noise suppression and auto gain on), then calls `POST …/conversation {on:true}`, keeps
695
1385
  the screen awake (Screen Wake Lock, taken again when the page becomes visible) and shows a level ring
696
1386
  around the mic with the current state: Listening, Hearing you, Transcribing, Sent ✓ and Speaking. Tap
697
1387
  it again, say "pause listening", or leave the page to turn it off. Leaving the page calls
698
1388
  `{on:false}` with `keepalive`. While conversation mode is off, tap-to-talk works as before.
699
1389
 
700
- - **VAD** (`web/conversation-core.js`, frames from `web/vad-worklet.js`, with a ScriptProcessor fallback):
701
- energy-based detection on about 20 ms frames. The noise floor falls fast and rises slowly, and the
702
- threshold is 10 dB above it with 4 dB of hysteresis. Speech must last 250 ms to count, and 300 ms of
703
- audio before it is kept. The threshold rises by 8 dB while the phone is speaking.
704
- - **Endpointing**: a segment ends after 550 ms of silence. It is encoded to 16 kHz mono 16-bit WAV
1390
+ - **Speech detection** (`web/src/core/speech-vad.ts`; ~20 ms frames from `web/public/vad-worklet.js`, with a
1391
+ ScriptProcessor fallback): on-device [Silero VAD](https://github.com/snakers4/silero-vad) v5 (MIT) through
1392
+ onnxruntime-web (MIT, wasm, single-threaded, so no COOP/COEP headers are needed) in a Web Worker
1393
+ (`web/src/voice/silero-worker.ts`). The mic audio is resampled to 16 kHz and cut into 32 ms chunks; each
1394
+ chunk's speech probability starts speech at ≥ 0.6 sustained for 250 ms (most of the onset voiced, so
1395
+ isolated clicks never add up) and ends it after 350 ms below 0.35. Typing, a fan, clatter, beeps and hum
1396
+ score low; speech scores high at any loudness. An energy detector stays as a cheap pre-gate (chunks within
1397
+ 3 dB of the adaptive noise floor skip the model) and as the fallback while the model loads, if it fails to
1398
+ load (logged once in the console) or stalls: noise floor that falls fast and rises slowly, threshold 10 dB
1399
+ above it with 4 dB of hysteresis, 300 ms to count. 300 ms of audio before the onset is kept. The model
1400
+ (2.3 MB) and the wasm (12 MB; 2.3 MB with Brotli) load when Voice mode first starts, as hashed static
1401
+ assets the hub serves with `cache-control: immutable` (and compressed when the browser accepts it), outside
1402
+ the main JS chunk; the Voice bar's tooltip says which detector runs and its time per chunk (about 1 ms).
1403
+ - **Mic sensitivity** (Settings → Voice, per device, Normal by default, applies at once): Low needs ≥ 0.75 for
1404
+ 350 ms (ends below 0.45; pre-gate 6 dB; energy fallback 13 dB / 400 ms), Normal as above, High ≥ 0.45 for
1405
+ 180 ms (ends below 0.25; no pre-gate margin; energy fallback 7 dB / 250 ms).
1406
+ - **Echo guard**: while a reply or a progress cue plays, and 400 ms after, a barge-in needs more: the model's
1407
+ probability ≥ 0.85 (Low 0.92, High 0.75) and 8 dB more energy. The browser's echo cancellation removes most
1408
+ of the reply from the mic first.
1409
+ - **Noise transcripts** (`web/src/core/junk-transcript.ts`): what speech-to-text makes of noise is dropped
1410
+ before it reaches the agent and shown dim in the Voice bar for 3 s ("ignored: “Thank you.”"): empty or
1411
+ punctuation-only text, sound tags ("[Music]", "(applause)", "♪"), Whisper's stock phrases ("Thank you.",
1412
+ "you", "Bye.", "Thanks for watching!", "Subtitles by …", "Amara.org" …), one word repeated, and one or two
1413
+ words from under 400 ms of speech unless they hold a command or answer word ("yes", "no", "okay", "stop",
1414
+ "cancel", "wait", "go ahead", "cancel that" …). With a Whisper model the hub also asks for `verbose_json` and
1415
+ leaves out segments Whisper rates `no_speech_prob` > 0.6 (OpenRouter and OpenAI-compatible providers; one
1416
+ that refuses `verbose_json` gets plain requests from then on).
1417
+ - **Endpointing**: a segment ends after 350 ms of silence. It is encoded to 16 kHz mono 16-bit WAV
705
1418
  and sent to `/api/transcribe` at once. A transcript that reads as complete is sent right away:
706
1419
  it ends in `. ? !`, or has 3+ words and doesn't end in "and", "but", "so", "um", "the" and the like.
707
- Otherwise it waits up to 1.5 s. If you speak again during the wait, the next segment's transcript
1420
+ Otherwise it waits up to 0.8 s. If you speak again during the wait, the next segment's transcript
708
1421
  is joined to it. Empty and filler-only transcripts are dropped. Utterances are capped at 60 s.
709
1422
  - **Commands**: an utterance that is only "stop", "cancel", "never mind", "stop that" or "hold on"
710
1423
  calls `POST …/interrupt` and stops speech. "Pause listening" turns the mode off.
711
- - **Speech out**: each SSE `say` is split into sentences. Abbreviations, decimals, file names and
1424
+ - **Speech out**: root replies arrive one sentence at a time while they are written (`say` with `seq`);
1425
+ the phone plays them in `seq` order per text part and drops duplicates (`SayAssembler`). A `say` without
1426
+ `seq` (a subagent's result) is split into sentences. Abbreviations, decimals, file names and
712
1427
  list items are handled, and fenced code becomes "(code omitted)". Each sentence goes to `/api/speak`,
713
- one ahead of playback, and plays through one `<audio>` element. Speech that lasts 250 ms or more
714
- (barge-in) pauses playback and clears the queue. The transcript shows your utterances with ✓ (sent)
1428
+ up to two ahead of playback, and plays through one `<audio>` element while its MP3 is still downloading:
1429
+ through MediaSource (`audio/mpeg` SourceBuffer; Chrome, Android, Firefox), appending bytes as they arrive
1430
+ (`web/src/core/audio-stream.ts`). Browsers without MP3 in MediaSource (some iOS Safari versions) play each
1431
+ sentence once it has fully downloaded, as before. Prefetched sentences keep downloading while they wait. Speech while a reply plays
1432
+ (barge-in, past the echo guard) pauses playback, clears the queue and mutes the rest of the replies that were speaking, also a reply still
1433
+ being written whose next sentence has not arrived yet (their later sentences still show in the transcript); a
1434
+ new reply speaks again. Barge-in never interrupts anything: what you say steers the agent, and background
1435
+ workers keep running (only a stop phrase interrupts the agent's turn).
1436
+ - **Turn timing**: each turn's steps are `performance.mark`s (`hub-voice:speech-end`, `…:transcript`,
1437
+ `…:accepted`, `…:first-say`, `…:first-byte` (the reply's first audio byte arrived), `…:first-audio` (it plays); visible in the browser's performance tools), and the Voice bar's
1438
+ state has the last turn as its tooltip: "last turn: 1.8 s to first audio" with the steps (transcript,
1439
+ accepted, first reply, first byte, audio). A typed turn counts from when the hub accepted it. The transcript shows your utterances with ✓ (sent)
715
1440
  and ✓✓ (heard). Agent text is merged with the bubble already streamed from deltas. Subagents
716
1441
  starting, finishing or failing appear as small notes. Typed text is sent as an utterance while the
717
1442
  mode is on.
@@ -762,12 +1487,31 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
762
1487
  assistant message); in conversation mode the spoken `say` text is merged into the bubble of the same message.
763
1488
  The home list shows the same "now" text with a spinner for working sessions.
764
1489
  - **Progress cues** (conversation mode; Settings → Voice → "Progress cues", on by default): a soft
765
- tone when the agent hears an utterance, then "Working on it." if the session is working and the
766
- agent has not spoken within 5 s. "Started a subagent." plays when a subagent starts, unless the agent
767
- spoke in the last 3 s. "Still working." plays after 40 s without speech while work continues, and
768
- a double tone plays when a subagent finishes. Failed tools are not spoken (they show in the feed). Cues never
1490
+ tone as soon as you stop speaking (the moment the segment goes to transcription; typed text gets it when
1491
+ the agent receives it), then a short acknowledgement ("On it.", "One moment.", "Let me check." …) if the
1492
+ session is working and the agent has not spoken within 2.5 s of the end of your speech. A hand-off phrase
1493
+ ("I'll hand that off.", "Getting a helper on it." …) plays when a subagent starts, unless the agent
1494
+ spoke in the last 3 s. A still-working phrase ("Still on it.", "Almost there." …) plays after 40 s without
1495
+ speech while work continues, and a double tone plays when a subagent finishes. Failed tools are not spoken (they show in the feed). Cues never
769
1496
  play while you are talking or the agent's speech is playing; they wait, and are dropped once the agent
770
- speaks. Each phrase is synthesized once per page load and cached in memory.
1497
+ speaks. Cues are event-driven: one timer for the next deadline (no polling), re-armed on speech, status and
1498
+ playback changes. Each cue has several variants (`CUE_VARIANTS` in `web/src/core/feedback-core.ts`), chosen
1499
+ from a shuffle-bag: every variant once before any repeats, never the same phrase twice in a row, and a variant
1500
+ whose audio is already loaded wins. All variants are synthesized, 2 at a time, when Voice mode starts (ending Voice mode
1501
+ cancels what is left) and kept in memory and in the browser's Cache Storage (`hub-cues-v2`, keyed by TTS provider, model, voice, speed and server, so a later
1502
+ session starts with them ready and a voice change fetches them again); a ready phrase starts playing at
1503
+ once. A phrase that is not ready yet when its cue fires plays a short tone instead.
1504
+ - **Cue loudness**: short TTS phrases come out several dB louder or quieter than the spoken replies, so each
1505
+ phrase is normalised while it is prefetched (never when it plays): decoded (`OfflineAudioContext`), measured
1506
+ (ITU-R BS.1770 K-weighted, gated loudness), trimmed to at most 80 ms of leading/trailing silence, gained to the
1507
+ replies' loudness with a look-ahead limiter keeping peaks at or below −1 dBFS, and stored as a 16-bit mono WAV
1508
+ (with the target it was normalised to; a target that moved by more than 1 dB re-normalises the cached audio
1509
+ without new synthesis). The target is −19.5 LUFS until replies are measured: each completed reply sentence of
1510
+ at least 1 s is decoded when the page is idle, and the median of the last 21 per TTS voice
1511
+ (`localStorage` `voice.replyLoudness`, clamped to −26…−14 LUFS) becomes the target. Audio that cannot be decoded
1512
+ plays as synthesized. Cues and replies keep their plain `<audio>` elements (no Web Audio playback graph, which
1513
+ would break background and iOS playback), both at volume 1 and the chosen speed. Pure parts:
1514
+ `web/src/core/loudness.ts`; browser decoding: `web/src/voice/cue-loudness.ts`.
771
1515
  - **Presence**: while a session view is open, the phone posts `POST /api/sessions/:id/presence
772
1516
  {visible}` on visibility changes and every 30 s while visible, and `{visible:false}` on `pagehide` and
773
1517
  when leaving the view. The hub uses this to decide when to push.
@@ -775,11 +1519,19 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
775
1519
  Screen Wake Lock keeps the screen on while listening. On: no wake lock. The mic and VAD keep running
776
1520
  when the page is hidden. A silent looping audio element plays to make suspension less likely. **This is best-effort:** whether
777
1521
  listening survives a locked screen depends on Android Chrome (battery saver, OS version), and iOS
778
- suspends it. The OpenCode session keeps running on the laptop either way. When the phone is not
779
- listening, turn on the `say` notification to get replies as push notifications.
1522
+ suspends it. Android Chrome usually stops the microphone when the screen turns off (the track is muted
1523
+ or ended, the AudioContext suspended, no audio frames). Voice mode watches for that: no frames for 1.5 s,
1524
+ a muted/ended track or a suspended AudioContext → the Voice bar says "Paused — mic stopped by the phone
1525
+ (screen off). Unlock to resume.", the notification becomes "Voice mode paused · unlock to talk", the lock
1526
+ screen controls show paused and a short falling tone plays. While hidden it keeps nudging the audio
1527
+ (resume every 3 s, the silent loop); it says "listening" again only once audio frames arrive. On unlock
1528
+ (visible, `pageshow`, focus, track unmute) it resumes the AudioContext, asks for the microphone again if
1529
+ the track ended (or shows "Tap to start Voice mode" when that needs a tap), retries with backoff while
1530
+ visible, and plays a rising tone. The OpenCode session keeps running on the laptop either way. When the
1531
+ phone is not listening, turn on the `say` notification to get replies as push notifications.
780
1532
  - **"Voice mode on" notification**: while Voice mode is on, the page shows a silent, persistent system
781
1533
  notification through the service worker (tag `voice-mode`): "<session title> · listening" (thinking,
782
- speaking, muted; updated at most every 3 s) with **End** and **Open**. End reaches the page (which ends
1534
+ speaking, muted; updated at most every 3 s, at once when the phone stops or gives back the microphone) with **End** and **Open**. End reaches the page (which ends
783
1535
  Voice mode); with no page open, the service worker turns conversation mode off at the hub itself with the
784
1536
  device cookie. It closes when Voice mode ends or the page goes away. It needs notification permission:
785
1537
  without it the Voice bar shows **Allow notifications**. The Media Session shows "Voice mode on" with the
@@ -829,6 +1581,10 @@ in the file).
829
1581
  | `speech.stt` | `{provider, model, baseURL?, language?, apiKey?}` |
830
1582
  | `speech.tts` | `{provider, model, voice, baseURL?, speed?, apiKey?}` |
831
1583
  | `auth` | `{mode, issuer?, clientId, clientSecret, allowed, admins}`; see [Multi-user](#multi-user-sign-in-with-google) |
1584
+ | `cluster` | `{database, valkey}` URLs for cluster mode (env `HUB_DATABASE_URL` / `HUB_VALKEY_URL` win); see [docs/cluster.md](docs/cluster.md) |
1585
+ | `agents.never` | permission patterns (`action[:glob]`) no agent may ever approve, for every user, checked before any grant; see [Agent actions and grants](#agent-actions-and-grants) |
1586
+ | `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60); see [docs/launches.md](docs/launches.md#placement-and-caps) |
1587
+ | `agents.webhooks.allowPrivate` | `true` lets webhooks target `http://`, loopback and private hosts (self-hosted receivers); see [docs/launches.md](docs/launches.md#where-webhooks-may-go) |
832
1588
 
833
1589
  Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
834
1590
  defaults. Speech: phone settings > hub.json > env vars. Values are checked like the phone's speech
@@ -975,6 +1731,7 @@ npm install # bun install works too
975
1731
  npm test # bun test (scripts/test-ci.sh runs each file alone and builds the web app first)
976
1732
  npm run typecheck # hub and web app
977
1733
  npm run build:web # web/dist, served by `bun bin/hub.ts`
1734
+ npm run build:cli # packages/cli/dist/hub-cli.js, the hub CLI bundle (prepack builds both)
978
1735
  ```
979
1736
 
980
1737
  Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
@@ -982,6 +1739,44 @@ Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (
982
1739
  `npm run test:e2e` runs a headless Chromium smoke test against the mock hub at desktop and phone sizes
983
1740
  (`CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to the temp dir).
984
1741
 
1742
+ `test/e2e-phase2.test.ts` does the same for messaging: the OpenCode spoke (with `deliver`) and a spoke without it,
1743
+ `hub msg`/`ask`/`mute` as session tokens, replies through the `hub` tool's code path, an app token with the
1744
+ `messages:*` scopes, `hub mcp` over pipes, a Decision answered on the console route, a second user who cannot see or
1745
+ answer any of it, the pair rate limit and the messaging switch. The opt-in `HUB_IT_OPENCODE=1 bun test
1746
+ test/e2e-phase2-live.it.test.ts` connects a private `opencode serve` to a real in-process hub: one session's (stub)
1747
+ model calls `hub msg` to another session, whose model is then handed the envelope.
1748
+
1749
+ `test/e2e-phase3.test.ts` does the same for agent actions: a spoke with `deliver`, `actTokens`, `hubPrompt` and
1750
+ `permissionMessage` and a raw spoke without them; a grant to `@@orchestrator` used through the `hub` tool's code
1751
+ path (act token) and an app token's grant through the CLI; shell tokens refused, never patterns (the grant's and
1752
+ hub.json's), the own-tree ban, `needs_user` escalated to a Decision answered on the console, the user answering
1753
+ first, both kill switches, the hourly cap, prompts with provenance, deny reasons, grant requests and the audit
1754
+ (admins see summaries only). `test/central-tenancy.test.ts` checks every phase-3 route and CLI verb across two
1755
+ users. The opt-in `HUB_IT_OPENCODE=1 bun test test/e2e-phase3-live.it.test.ts` connects a private `opencode serve`
1756
+ with shell permissions set to ask: session A's (stub) model runs a shell command, session B's model calls `hub
1757
+ approve` under its grant, and OpenCode runs A's command. `HUB_IT_OPENCODE=1 bun test test/spoke-acting.it.test.ts`
1758
+ checks hub prompts and deny reasons inside OpenCode.
1759
+
1760
+ `test/e2e-phase1.test.ts` (part of `npm test`) runs agent identity end to end in process: a hub, the OpenCode spoke
1761
+ and a second spoke of another harness, and the `hub` CLI as a session token, an app token, a linked device and a
1762
+ second user. The opt-in `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.test.ts` starts a private
1763
+ `opencode serve` (own port, password and XDG dirs) with a local stub model and checks a real shell: its
1764
+ `HUB_SESSION_TOKEN`, and that `hub whoami` there prints the session. `HUB_IT_OPENCODE=1 bun test
1765
+ test/spoke-deliver.it.test.ts` does the same for hub messages: the model sees the delivered envelope, a repeated
1766
+ message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin. `HUB_IT_OPENCODE=1 bun test test/spoke-hub-tool.it.test.ts` has the stub model call the `hub` tool and checks
1767
+ that the hub sees the session's token while the model and the transcript never do. `HUB_IT_OPENCODE=1 bun test
1768
+ test/spoke-prompt-refs.it.test.ts` checks that a prompt naming a session reaches the model unchanged with the
1769
+ reference note attached, and that hub-sourced prompts get none.
1770
+
1771
+ `test/e2e-phase7.test.ts` runs the other harnesses end to end in process (the Claude Code daemon fed fixture hook
1772
+ inputs, the Codex spoke on a fake shared app-server daemon, a preset on a private tmux server, and claude-code and
1773
+ codex launches in node worktrees), and `test/e2e-phase7-conformance.test.ts` runs the spoke conformance suite against every shipped spoke
1774
+ (OpenCode included). The opt-in `HUB_IT_CLAUDE=1 bun test ./packages/claude-code/test/live.it.test.ts` and
1775
+ `HUB_IT_CODEX=1 bun test ./packages/codex/test/live.it.test.ts` run the real CLIs against the testkit's stub model
1776
+ with a throwaway `HOME`, `CLAUDE_CONFIG_DIR` / `CODEX_HOME` and a deny-all proxy. A new harness spoke starts from
1777
+ `bun packages/spoke-sdk/scaffold.ts <name> <dir>`, whose output passes `npx hub-conformance spoke --driver "bun
1778
+ <dir>/driver.ts"` as written ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
1779
+
985
1780
  Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
986
1781
  `{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
987
1782
  OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
@@ -1016,7 +1811,9 @@ publish before trusted publishing can take over (see the comment in `release.yml
1016
1811
 
1017
1812
  - Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no
1018
1813
  streaming STT provider yet.
1019
- - Voice detection is a simple energy detector; noisy rooms may need tuning.
1814
+ - Voice detection tells speech from noise, not whose speech it is: a TV or people talking nearby still count
1815
+ (Mic sensitivity Low helps; mute or headphones are the sure way).
1816
+ - The compiled binaries and the Docker image grow by about 14 MB (the speech model and onnxruntime's wasm).
1020
1817
  - Questions (forms) of a headless standalone server (`opencode serve --stdio` without its TUI) cannot be
1021
1818
  answered from the hub; the background service, a foreground `opencode serve` and TUI-launched
1022
1819
  standalone servers can.
@@ -1024,6 +1821,13 @@ publish before trusted publishing can take over (see the comment in `release.yml
1024
1821
  - Listening with the phone screen off depends on the browser (best-effort on Android Chrome; not
1025
1822
  possible on iOS). Replies and alerts still arrive as push notifications.
1026
1823
  - The voice agent registers as a visible primary agent (`voice`) in OpenCode's agent list.
1824
+ - A catalog change still sends every machine's full catalog to each stream (about 1 KB per few sessions); the
1825
+ replay buffer is in memory (a hub restart means a full catalog + `resync`).
1826
+ - Running shells are seeded from the spoke's own server (`shell.list` for the 20 most recent session
1827
+ directories); shells in other directories show up once they emit an event.
1828
+ - Claude Code, Codex and presets serve fewer operations than OpenCode (no timeline or summary; Claude Code takes
1829
+ hub turns only in sessions the hub launched, and its permission requests race its own dialog); see
1830
+ [docs/harnesses.md](docs/harnesses.md#known-limits).
1027
1831
 
1028
1832
  ## License
1029
1833