@huynle/hub 0.6.1 → 0.7.2

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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -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 +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. 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
 
@@ -97,6 +133,75 @@ Optional: put the voice agent on a fast model in `~/.config/hub/config.json`:
97
133
  `"voiceModel": "anthropic/claude-haiku-4-5"` (any `provider/model` you have). Subagents keep the
98
134
  session's model.
99
135
 
136
+ ## Quick start (hubctl, before any harness)
137
+
138
+ `hubctl` is a standalone, signed program (Bun and the CLI compiled into one file; no bun, node or harness needed)
139
+ that bootstraps a machine into Hub: it enrolls the machine, then previews and applies a typed plan that installs
140
+ pinned harnesses, edits their configs losslessly and writes hub's user services. `hub` and `hubctl` are the same
141
+ program; the name grants nothing (only the machine credential and your consent flags do). Nothing is run through a
142
+ shell, and the hub never gets a way to run commands here.
143
+
144
+ ```sh
145
+ # 1. Download hubctl for your platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64) and the signed manifest
146
+ # from the release, then install the binary yourself (the first apply installs the release's copy as
147
+ # ~/.local/bin/hubctl, with `hub` linked to it):
148
+ curl -fsSLo hubctl https://github.com/huynle/hub/releases/download/v<version>/hubctl-darwin-arm64 && chmod +x hubctl
149
+ curl -fsSLO https://github.com/huynle/hub/releases/download/v<version>/hubctl-manifest.json
150
+
151
+ # 2. Enroll this machine (device code: approve it on the hub; Ctrl-C cancels). One identity for every spoke here.
152
+ ./hubctl machine enroll --server https://hub.example.com
153
+ ./hubctl machine status # verified | revoked (exit 9) | offline (exit 7) | insecure (plain-http hub)
154
+
155
+ # 3. Preview: discover the host and print the exact plan and any manual edits. It writes nothing.
156
+ ./hubctl setup preview --profile session-only --manifest hubctl-manifest.json --out preview.json
157
+
158
+ # 4. Apply exactly that plan (rediscovered and re-planned; refused when the host changed since, exit 12). A harness
159
+ # that is installed and runs but still waits on you (provider login, spoke not started) is committed as
160
+ # "installed, not ready" with the next steps; --require-ready fails (exit 15) and rolls back instead.
161
+ ./hubctl setup apply --preview preview.json --manifest hubctl-manifest.json
162
+
163
+ # 5. Do the next steps it printed (e.g. `opencode auth login`, start OpenCode), then check readiness with real
164
+ # probes (version, provider login, spoke connected; exit 15 when not ready), and undo whenever you like.
165
+ hubctl setup verify
166
+ hubctl setup rollback # or --to <txID>; files you changed since are kept and reported
167
+ ```
168
+
169
+ - Profiles: `session-only` (OpenCode), `launch-node` (+ `hub node` as a user service; give
170
+ `--workspace-root <dir>`), `full-harness` (OpenCode, Claude Code, Codex and their spokes), or a profile file
171
+ (`--profile <file>` or `-` for stdin).
172
+ - Consent is explicit and never a default: `--confirm-activation` (load the user services; give it at preview and
173
+ again at apply), `--escalate` (system locations), `--allow-downgrade`.
174
+ - `--enrollment separate` leaves enrollment to `hubctl machine enroll` (the default is inline when you pass
175
+ `--server` to preview). A config behind a symlink (dotfiles) is never written through: the preview prints the
176
+ exact edit and the file it belongs in.
177
+ - `hubctl setup drift` reports hub's managed fields (in sync, missing, outdated, changed by you);
178
+ `hubctl setup recover [--resume]` finishes or rolls back an interrupted apply.
179
+ - With `--json`, every command prints one `hub.bootstrap.result` envelope; exit codes are listed in
180
+ [docs/bootstrap-contracts.md](docs/bootstrap-contracts.md#311-output-envelope-and-exit-codes) (10 conflict,
181
+ 11 artifact rejected, 12 preview invalid, 13 locked, 14 recovery required, 15 not ready).
182
+ - Many machines: the optional Ansible role in [contrib/ansible](contrib/ansible/roles/hub_bootstrap/README.md)
183
+ runs the same preview and apply (`hubctl setup ansible` renders its vars; enrollment stays a separate step).
184
+
185
+ ### Start a launch from a terminal
186
+
187
+ On a machine set up with `hubctl setup` (or the OpenCode plugin), `hub` is on your PATH: `~/.local/bin/hub` links to
188
+ hubctl, or (plugin only) to the plugin's launcher; if `~/.local/bin` is not on your PATH, both say so once. A machine
189
+ enrollment identifies the **machine**, never you, so launching from your own shell needs a user login first:
190
+
191
+ ```sh
192
+ hub login --scope sessions:launch # prints a link and a code; approve it in the console as yourself
193
+ # (the page lists the scopes; tick "sessions:launch" if you did not ask)
194
+ hub launch --repo ~/projects/app --branch feat/x --prompt "Fix the flaky test"
195
+ ```
196
+
197
+ `hub login` without `--token` is a device code (Ctrl-C cancels); its URL defaults to `HUB_URL`, your last login, else
198
+ this machine's enrollment. You get an app token with the scopes you approved (default `sessions:read`,
199
+ `messages:send`, `messages:read`), stored owner-only in `~/.config/hub/cli.json`, and revocable in Settings → Agents.
200
+ An app token launches only under a **grant** with verb `launch` covering the repo: create it in Settings → Agents, or
201
+ ask for it from the terminal (`hub grant request --project ~/projects/app --allow launch --why "…"`) and approve it.
202
+ Without a user login, `hub launch` on an enrolled machine says so (`this machine is enrolled (mch_…), but launching
203
+ needs a user login: run hub login`). `hub login --token <app token>` (or a token piped on stdin) still works.
204
+
100
205
  ## Running the hub
101
206
 
102
207
  The hub serves the web app, pairs devices, enrolls machines, and does push and (for Voice) speech. Pick one:
@@ -107,6 +212,7 @@ The hub serves the web app, pairs devices, enrolls machines, and does push and (
107
212
  | **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub/releases) |
108
213
  | **Docker** | Servers | `ghcr.io/huynle/hub` (linux/amd64, linux/arm64) |
109
214
  | **bunx / from source** | Bun users, development | `bunx --package @huynle/hub hub-server` · `bun bin/hub.ts` |
215
+ | **Kubernetes (cluster mode)** | Several pods on Postgres + Valkey, rolling updates | [docs/cluster.md](docs/cluster.md), manifests in [`deploy/k8s/`](deploy/k8s/) |
110
216
 
111
217
  Every way reads the same configuration (env vars over `hub.json`, see [Configuration](#configuration)) and
112
218
  answers `--help` and `--version`. While no device is paired, the hub logs a pairing code for the first one.
@@ -160,8 +266,9 @@ foreground with `hub.json`; `scripts/io.github.huynle.hub.plist` is a launchd te
160
266
  **Security:** the hub can drive your OpenCode sessions, which can run tools. Every API call needs a
161
267
  paired device or a signed-in user; spokes need machine tokens; nothing trusts the client IP. For a public
162
268
  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.
269
+ front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, `/.well-known/hub`, the manifest and
270
+ icons reachable for spokes, sign-in, app discovery and the PWA (and `/mcp`, bearer-authenticated, if MCP clients
271
+ or gateways use [MCP over HTTP](#over-http-mcp)).
165
272
 
166
273
  ## Configuration
167
274
 
@@ -189,18 +296,616 @@ conversation mode and utterances (403 `not_exposed` for sessions not shared with
189
296
 
190
297
  | Route | Result |
191
298
  |---|---|
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) |
299
+ | `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" |
300
+ | `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) |
301
+ | `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) |
302
+ | `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 |
303
+ | `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
304
  | `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
305
  | `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
306
  | `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
307
  | `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) |
308
+ | `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 |
309
+ | `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` |
310
+ | `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
311
  | `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 |
312
+ | `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
313
  | `PUT /api/push/mute {sessionID, muted}` | mutes or unmutes a session's notifications for the user: `{muted}` |
203
314
 
315
+ ## Agent identity (agent API)
316
+
317
+ **Organization.** `PUT /api/agent/sessions/:addr/archive {archived: true|false}` archives or unarchives one
318
+ session in Hub views without deleting it in its harness. Users always may; app tokens need `sessions:manage`.
319
+ The operation is idempotent, returns `{sessionID, archived, changed}`, and preserves unrelated view preferences.
320
+ MCP exposes the same operations as `archive` and `unarchive`. Naming (`POST /api/agent/name`) also takes an app
321
+ token with `sessions:manage`; role claims and releases take one with `sessions:manage` **and** a grant allowing
322
+ `roles` whose projects cover the session's directory (the session must be online), under the kill switch and hourly
323
+ cap, with every outcome in the agent audit (verbs `roles` and `roles.release`). Reserved roles stay yours to assign.
324
+ Apps and users name the session (`session`); foreign and unknown sessions are both `404 not_found`. MCP: `name`,
325
+ `role_claim` and `role_release`. Each change is one `session.named`, `role.claimed` or `role.released` event.
326
+
327
+ **Events.** `GET /api/agent/events?cursor=&wait=&limit=&type=&session=` reads a bounded per-user journal (24 h or
328
+ 10,000 events) of compact events: `session.added/removed/changed`, `blocker.opened/closed`, `machine.changed`,
329
+ `message.changed`, `launch.changed`, `session.archived`, `session.named`, `role.claimed`, `role.released`. Each is `{id, at, type, subject, data}` with IDs and states
330
+ only: never prompts, message bodies, titles, blocker summaries, launch reasons or labels. Without a cursor the answer
331
+ is empty with a cursor at the latest event; pass the returned `cursor` back to get what came after. `wait` (0–60 s)
332
+ holds the request while nothing matches; `limit` is 1–200 (default 50); `type` (exact, or `launch.*`) and `session`
333
+ may repeat and still advance the cursor over what they skip. At most 1,000 events are inspected per request; `more`
334
+ means some remain. A cursor the journal no longer covers (expired, trimmed, hub restarted) answers
335
+ `{events: [], cursor, more: false, reset: true, refresh: ["status", "sessions", "inbox", "decisions", "launches"]}`:
336
+ re-read those, then continue with the new cursor. `GET /api/agent/watch` streams the same events live over SSE
337
+ (`{type: "ready"}` first, `{type: "reset", refresh}` when its cursor is no longer covered, `event: ping` while
338
+ idle), with the same visibility and filters (`blocked=1` is `type=blocker.*`). A malformed, forged or another user's cursor is
339
+ `400 invalid_cursor`. Unknown event types are valid; ignore them. App tokens need `events:subscribe`, and see each
340
+ family only with its scope: session and machine events with `sessions:read`, archive, name and role events with `sessions:manage`,
341
+ messages they are a party to with `messages:read`, launches they started with `sessions:launch`. MCP: `events`.
342
+
343
+ Agents, scripts and apps talk to the hub through `/api/agent/*`, a small API with its own credentials, separate
344
+ from the console's routes. Every request resolves to exactly one **actor**:
345
+
346
+ | Actor | Credential | Who holds it |
347
+ |---|---|---|
348
+ | 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 |
349
+ | session | a **session token** `hst1.…` the hub mints for each session a spoke reports | that session's shells (`HUB_SESSION_TOKEN`) |
350
+ | app | an **app token** `hat_…` you create (scopes `sessions:read`, `sessions:manage`, `messages:send`, `messages:read`, `sessions:act`, `sessions:launch`, `events:subscribe`) | a script or tool outside a session (`hub login`) |
351
+
352
+ **Session tokens in shells.** The hub mints a 24 h token for every session (subagents included) its spokes report
353
+ for you and pushes it to the spoke, which keeps it in memory only. Every shell an agent starts through OpenCode's
354
+ shell tool gets that session's `HUB_SESSION_TOKEN` and `HUB_URL` (the spoke's hub), with the hub bin dir first on
355
+ PATH, so [`hub`](#hub-cli) inside a session acts as that session: `hub whoami` prints `session ses_… on machine
356
+ mch_…`. Tokens are renewed automatically before they expire; a shell started earlier keeps its old token, so a
357
+ `token_expired` answer means: start a new shell. A token stops working as soon as the session is no longer
358
+ reported by that machine, the machine is revoked or the user is disabled (see
359
+ [docs/multi-user.md](docs/multi-user.md#agent-actors-and-tokens)). Spokes of other harnesses get tokens only
360
+ when they announce the `sessionTokens` feature. Each session also has a separate **act token**, which never goes
361
+ into a shell; see [Agent actions and grants](#agent-actions-and-grants).
362
+
363
+ **Addressing.** Wherever a route or command takes a session, it takes an address, resolved among the caller's
364
+ own sessions only:
365
+
366
+ | Address | Means |
367
+ |---|---|
368
+ | `ses_…` | a session ID, or an unambiguous prefix of one |
369
+ | `@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` |
370
+ | `@me` | the user (messages and Decisions; never a session) |
371
+ | `@@role` | the session(s) holding a role (`hub role claim reviewer`); `--exclusive` claims fail while another live holder exists |
372
+ | `@name.project`, `@@role.project` | scoped to a project (its name, else the folder name) |
373
+
374
+ An address several sessions match answers `ambiguous` with the candidates; another user's sessions, names and
375
+ roles do not exist (`not_found`). A name is released 7 days after its session is archived or deleted; a role
376
+ holder offline for 30 minutes loses the role. Some role names (such as `orchestrator…`) are reserved to the user.
377
+
378
+ In an OpenCode prompt, an address is a reference, not routing: the prompt still goes to the session you typed it
379
+ in, word for word, and the plugin attaches a short `hub-references.txt` note naming the session(s) it means (state,
380
+ machine, project, your open asks to it) so the agent can contact them with the `hub` tool. OpenCode's own `@file`
381
+ and `@agent` mentions win; unknown names add nothing; if the hub does not answer within 1.5 s the prompt goes on
382
+ without the note.
383
+
384
+ **Routes.** Errors are `{error: {code, message, hint?, …}}` with the codes the [CLI](#hub-cli) maps to exit
385
+ codes. Console routes refuse session and app tokens (403); `/api/agent` refuses a request carrying both the
386
+ cookie and a hub bearer token (400).
387
+
388
+ | Route | User | Session | App (`sessions:read`) | Result |
389
+ |---|---|---|---|---|
390
+ | `GET /api/agent/whoami` | ✓ | ✓ | ✓ (any scope) | `{actor, user}` |
391
+ | `GET /api/agent/status` | ✓ | ✓ | ✓ | machines → projects → sessions, each with name, roles, state, harness, activity and blockers, plus `attention` |
392
+ | `GET /api/agent/sessions?project=&state=&machine=&label=k=v` | ✓ | ✓ | ✓ | the same sessions as a flat list |
393
+ | `GET /api/agent/sessions/:addr` | ✓ | ✓ | ✓ | one session with full blockers and the spoke's summary (`unsupported` when its harness lacks it) |
394
+ | `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` |
395
+ | `GET /api/agent/watch?blocked=1&session=<addr>&cursor=` | ✓ | ✓ | ✓ (+ each family's scope) | SSE of the same compact events as `/api/agent/events` (see **Events**), after `ready`; each frame's `id:` is a cursor to resume with (`?cursor=` or `Last-Event-ID`); `reset` when it is no longer covered |
396
+ | `GET /api/agent/events?cursor=&wait=&limit=&type=&session=` | ✓ | ✓ | `events:subscribe` (+ each family's scope) | compact events after a resumable cursor (see **Events** above) |
397
+ | `GET /api/agent/who?project=&role=` | ✓ | ✓ | ✓ | named, role-holding and online sessions |
398
+ | `POST /api/agent/name {name, session?}` | ✓ | own session | `sessions:manage` | names a session (`409 name_taken`) |
399
+ | `POST /api/agent/roles/claim {role, exclusive?, project?, session?}` · `…/roles/release` | ✓ | own session | `sessions:manage` + `roles` grant | claims (with a new `generation`) or releases a role; never a reserved role for an app |
400
+ | `POST /api/agent/msg {to, text, wake?, urgent?, all?}` | ✓ | ✓ | `messages:send` | tells a session (`all`: every holder of a role); never yourself |
401
+ | `POST /api/agent/ask {to, text, expiresIn?, wait?}` | ✓ | ✓ | `messages:send` | an ask, open until answered, declined or expired; `wait` (≤ 60 s) returns the `answer` |
402
+ | `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?}` |
403
+ | `GET /api/agent/decisions?state=` · `…/decisions/:id` | ✓ (all) | own | `messages:read` (own) | the Decisions the caller asked |
404
+ | `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 |
405
+ | `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 |
406
+ | `POST /api/agent/mute {session}` · `…/unmute` | ✓ | ✓ | `messages:send` | refuses (or allows again) messages from a session |
407
+ | `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
408
+ | `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
409
+
410
+ The act verbs, grants, kill switches and the agent audit are in [Agent actions and grants](#agent-actions-and-grants).
411
+
412
+ **Decisions and `@me`.** `@me` addresses the user: `msg @me` is stored in the user's inbox (push kind `message`);
413
+ `ask @me` opens a Decision `{id: "dec_…", from, about, question, options: [{id, label, safe?}], recommendation:
414
+ {option, why?}, urgency: low|normal|high, tier: confirm|tap, deadline, onTimeout, state, answer?}` (push kind
415
+ `decision`, generic body, opens `about[0]`, default the asking session). Only the user answers, on the console:
416
+ `GET /api/decisions?state=`, `GET /api/decisions/:id`, `POST /api/decisions/:id/answer {option, text?}` (`text` alone
417
+ for a Decision without options; same-origin cookie or a device token; `tap` Decisions only with the console
418
+ cookie, else `403 forbidden`). The first answer wins, a later one gets `409 {code: "gone"}`; the answer reaches the
419
+ asker as the user's reply to its ask (`Decision dec_…: Hold [hold]`). At the deadline, `onTimeout` (only an option
420
+ marked `safe`) answers it (`answeredBy: "timeout"`); without one the Decision stays open and is pushed again once.
421
+ `deadline` is in seconds and must fall before the ask expires (default: a day past the deadline, at most 7 days).
422
+ The console shows open Decisions as cards on Home and in the sessions they concern (SSE `{type: "decision",
423
+ decision}` on every open, answer, timeout, escalation and expiry); a `decision` push opens its card
424
+ (`?decision=<id>`).
425
+
426
+ Reads come from the hub's catalog; only `show` (summary) and `log` (timeline) ask the session's spoke, so they
427
+ depend on what its harness supports. A session token reads all of its user's sessions but names and claims roles
428
+ only for itself; an app token reads, and messages only with the `messages:*` scopes. `GET /.well-known/hub` (public) answers `{publicURL, version,
429
+ minClientVersion, auth, features}` for apps and CLIs.
430
+
431
+ **Apps.** A native app links with a code from Settings → Devices → **Add app** (`POST /api/devices/link`, then
432
+ `POST /api/devices/redeem` from the app; see [Multi-user](#multi-user-sign-in-with-google)) and uses the device
433
+ token as a user actor, on console routes and `/api/agent` alike. Revoking the device ends it.
434
+
435
+ ## Agent messaging
436
+
437
+ Sessions, scripts, apps and you can message each other through the hub. Everything goes through the
438
+ [`/api/agent` routes](#agent-identity-agent-api) above; the sender is always the credential that made the request
439
+ (a session token, an app token or your device), never a field of the request.
440
+
441
+ **Addresses.** `@name` (a session's name), `@@role` (the session holding a role: the idle holder, else the most
442
+ recently active one; `--all` sends to every holder), `@name.project` / `@@role.project`, `ses_…`, and `@me` for
443
+ you. Addresses resolve among your own sessions only; another user's sessions do not exist (`not_found`).
444
+
445
+ **Verbs.** The same verbs exist in the CLI (`hub …`), the OpenCode `hub` tool, `hub mcp` and the SDK:
446
+
447
+ | Verb | What it does |
448
+ |---|---|
449
+ | `msg <addr> "text"` | tells a session something (fire and forget) |
450
+ | `ask <addr> "question"` | an ask, open until answered, declined or expired (1 minute to 7 days, default 24 h); `--wait` blocks for the answer |
451
+ | `reply <msgID> "text"` · `decline <msgID> --why "…"` | answers a message you received; it goes to the message's real sender, never through the role |
452
+ | `inbox` | asks waiting for your answer, your own open asks, recent messages, mutes |
453
+ | `mute <addr>` · `unmute <addr>` | refuses (or accepts again) messages from a session (`forbidden`, exit 5) |
454
+ | `who` | named and role-holding sessions |
455
+
456
+ `--no-wake` admits a message without starting a turn and `--urgent` steers it into a running turn. Texts are at
457
+ most 10,000 characters (a decline's reason 1,000).
458
+
459
+ **Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
460
+ OpenCode's synthetic message; it shows in the console's conversation as a message row). An idle session gets a
461
+ turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it; subagent sessions and
462
+ `--no-wake` messages wait for the next turn. A session that is offline gets it when it comes back (pending, for 24
463
+ hours). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
464
+ `unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
465
+ 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`).
466
+
467
+ **Envelope.** The recipient sees the message framed so its body cannot pose as anything else:
468
+
469
+ ```
470
+ [hub] ask from @api (laptop) to @@reviewer → you · 10-24 08:15Z · msg_7f3a…
471
+ ----- msg_7f3a… -----
472
+ please review the login fix
473
+ ----- msg_7f3a… -----
474
+ ↩ hub reply msg_7f3a… "…" · hub decline msg_7f3a… --why "…" · expires 10-25 08:15Z
475
+ ```
476
+
477
+ The boundary carries the message's own id; body lines that look like a header, a boundary or a reply hint are
478
+ escaped with a leading backslash; control characters, ANSI escapes, bidi overrides and zero-width characters are
479
+ removed from the text and from every interpolated name.
480
+
481
+ **Primer.** Once per session, the first time it is named explicitly or gets its first message, the hub adds a short
482
+ note without starting a turn: who the session is (`@name`, its roles), how to message others (`hub msg`, `hub who`),
483
+ and that messages headed `[hub]` come from other sessions or apps, not from the user. When that first message
484
+ wakes an idle session, the note and the message reach the model together, in one model call.
485
+
486
+ **Decisions.** `ask @me` from a session or app opens a Decision (question, options, a recommendation, urgency, a
487
+ deadline with an optional safe `onTimeout`). You answer it from the console (cards on Home and in the sessions it
488
+ concerns, a `decision` push); the answer goes back to the asker as your reply. Only users answer Decisions, never
489
+ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the routes.
490
+
491
+ **Where agents send from.**
492
+ - In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
493
+ - The model itself: the plugin's `hub` tool (verbs `msg`, `ask`, `reply`, `decline`, `inbox`, `who`, `status`,
494
+ `whoami`, and the [act verbs](#agent-actions-and-grants)), as the calling session ([Spoke](#spoke-opencode-plugin)).
495
+ - You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
496
+ - Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with, or the hub's
497
+ [`POST /mcp`](#over-http-mcp) as the bearer token sent.
498
+ - Scripts: `hub login` with an app token that has the `messages:send` (and `messages:read`) scopes.
499
+
500
+ **Limits.** Per 10 minutes: 20 messages per sender→recipient pair and 300 per user (`rate_limited`, exit 6, with
501
+ `retryAfter`), and 30 turns started per recipient (more messages are still delivered, without waking it). A chain of
502
+ replies between agents may run 8 hops without a human turn; past that, messages are delivered without waking and
503
+ you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. You can switch
504
+ messaging off for your account (`PUT /api/agent/messaging {enabled: false}`): sends then answer `paused` (exit 5),
505
+ reading still works.
506
+
507
+ ## Agent actions and grants
508
+
509
+ Agents can do more than message: a session or a script may **approve** or **deny** a permission request, **answer**
510
+ a form, **prompt** a session or **stop** it, in your sessions and only as far as you allow. You always may (your
511
+ own sessions, any of your credentials); an agent acts only under a **grant** you created, and anything outside it
512
+ comes back to you as a Decision.
513
+
514
+ **Who can act.**
515
+
516
+ | Credential | Acts? |
517
+ |---|---|
518
+ | you (device cookie, device token) | always, on your own sessions; no grant, no kill switch |
519
+ | 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) |
520
+ | an app token with scope `sessions:act` | under a grant held by `app:<token id>` |
521
+ | a session's **shell token** (`HUB_SESSION_TOKEN`) | never: `403 forbidden` |
522
+
523
+ **Act tokens vs shell tokens.** Every session has two hub tokens. The shell token goes into the shells the session
524
+ starts, so `hub msg` and the other read and message verbs work from any script it runs. The act token (`typ act`)
525
+ is pushed only to spokes that announce `actTokens`, kept apart from the shell tokens, never put in any
526
+ environment and never shown in tool output; only the plugin's `hub` tool uses it, for the act verbs. A command an
527
+ agent runs in a shell therefore cannot act, even with the session's own credential; the model has to call the tool,
528
+ where the call is visible and audited.
529
+
530
+ **Grants.** A grant says who may do what, where, until when:
531
+
532
+ ```json
533
+ {
534
+ "holder": "@@orchestrator",
535
+ "scope": { "projects": ["/home/you/projects/api"] },
536
+ "allow": ["approve", "deny", "prompt", "stop"],
537
+ "permissions": { "allow": ["shell:bun test*", "edit:src/*"], "never": ["shell:git push*"] },
538
+ "expires": "8h",
539
+ "maxActionsPerHour": 60,
540
+ "allowAlways": false
541
+ }
542
+ ```
543
+
544
+ - **holder**: `ses_…` or `@name` (pinned to that session when the grant is created; the grant never follows the name
545
+ to another session), a reserved `@@orchestrator…` role (follows whoever holds it when it is used; only you assign
546
+ reserved roles), or `app:<token id>` (one of your app tokens).
547
+ - **scope.projects**: 1–20 absolute project directories; a session is in scope when its directory is one of them or
548
+ inside one (on a path boundary, after resolving `.`/`..`; worktrees inside the project count).
549
+ - **allow**: the verbs (`approve`, `deny`, `answer`, `prompt`, `stop`; `roles` lets an app token with `sessions:manage` claim and release roles of sessions in scope).
550
+ `deny`, `answer`, `prompt` and `stop` need only the verb; `approve` also needs the request to pass the patterns.
551
+ - **permissions**: patterns `action[:glob]` in OpenCode's wildcard syntax (`*` any run, `?` one character, a trailing
552
+ ` *` optional; `bash` is OpenCode's `shell`; the action alone means every resource). A request is allowed only
553
+ when **every** resource it names matches an allow pattern; any never pattern wins over every allow. In shell allow
554
+ patterns, `$(`, backticks and `<`/`>` must be written literally (a wildcard never matches them), so
555
+ `shell:bun test*` does not allow `bun test $(rm -rf ~)`. Never patterns also see the command without leading
556
+ `VAR=…` assignments and wrappers (`sudo`, `env`, `command`, `exec`, `nohup`, `time`), so `sudo git push` is still
557
+ a `git push`.
558
+ - **expires**: 1 minute to 30 days (seconds, or `30m`, `8h`, `2d`; default 8 h). **maxActionsPerHour**: 1–1000
559
+ (default 60), per clock hour; past it `429 rate_limited`. **allowAlways**: lets the holder reply `always` (OpenCode
560
+ then saves the request's rules), and only when every rule it would save lies inside the allow patterns.
561
+ - At most 50 active grants per user. Editing replaces a grant (its holder stays, its expiry restarts); revoking ends
562
+ it at once and keeps it listed for the audit.
563
+
564
+ **hub never rules.** `agents.never` in [hub.json](#hubjson) lists patterns no agent may approve for any user,
565
+ checked before every grant (`{"agents": {"never": ["shell:git push*", "shell:rm -rf*", "external_directory"]}}`).
566
+ The user can still approve such a request themselves.
567
+
568
+ **The check order** for an agent: the session belongs to you (else `not_found`, like a session that does not
569
+ exist) → kill switches → a usable grant for the verb and the session's project → not its own session tree (a
570
+ session never answers a request or form in its own tree) → the hub's never rules → the grant's never rules → the
571
+ allow patterns (else `needs_user`) → the hourly cap → the kill switches again → the spoke.
572
+
573
+ | Answer | HTTP | CLI exit | Means |
574
+ |---|---|---|---|
575
+ | ok | 200 | 0 | done (the result names the grant used) |
576
+ | `needs_user` | 403 | 4 | outside the grant: the user decides (a Decision is open) |
577
+ | `forbidden` | 403 | 5 | no grant, a never rule, its own tree, or a shell token |
578
+ | `paused` | 423 | 5 | a kill switch is on |
579
+ | `rate_limited` | 429 | 6 | the grant's hourly cap |
580
+ | `not_found` | 404 | 3 | not your session, or no such pending request or form |
581
+ | `gone` | 410 | 8 | answered elsewhere first, expired or ended |
582
+ | `unsupported` | 422 | 7 | the session's spoke cannot do it (e.g. an agent's prompt without `hubPrompt`) |
583
+ | `offline` | 503 | 7 | no spoke reports the session now |
584
+
585
+ **Escalation.** An approval outside the grant answers `needs_user` with the evaluation (`verdict`, `reason`, the
586
+ grants checked, and a `tier`) and opens a Decision for you: one per agent and request (asking again updates its
587
+ reason), with Approve once (or Approve always, when asked), Deny (safe), and the agent's `--why` as its
588
+ recommendation. The hub sets the tier, never the agent: `tap` (answered by a tap in the console, not from a
589
+ notification) for shell, `external_directory`, MCP and unknown tools and for anything near a never rule; `confirm`
590
+ otherwise. Your answer replies to the permission **as you** (no grant or kill switch applies to you); if the request
591
+ was answered elsewhere or ended first, the Decision closes and the agent's next try is `not_found`. A request's
592
+ blocker in the console shows the recommendation ("@orchestrator recommends Approve once: …"). Limits: 20 new
593
+ escalations per agent an hour and 20 open at once per user; escalations within a minute of the last push open
594
+ quietly.
595
+
596
+ **Grant requests.** Agents never create grants. A session (any of its tokens) or an app token with `messages:send`
597
+ may **request** one: `POST /api/agent/grants/requests {grant, why}` (`hub grant request …`). The holder defaults to
598
+ the requester and may only be the requester itself or a reserved role it holds. It opens a tap Decision summarising
599
+ the grant; approving creates it as yours, and the requester is told the grant id in a reply. One open request per
600
+ requester, 5 an hour.
601
+
602
+ **Prompts.** An agent's prompt reaches the session as the hub's, never as yours: the spoke wraps it in a provenance
603
+ envelope (`[hub] prompt from @lead · grant g_… · msg_…`, the body between boundary lines) and admits it as a hub
604
+ message, so it never resets the loop protection's hop chain the way your own typing does. Agents may prompt root
605
+ sessions only, never themselves, text only, always waking the session (`--delivery queue|steer` picks how it enters
606
+ a busy one).
607
+
608
+ **Deny reasons.** `deny --why "…"` passes the reason to the harness as the rejection's feedback (prefixed with who
609
+ denied it and under which grant), on spokes that announce `permissionMessage`; an older spoke takes the reject
610
+ without it, and the answer says so (`reasonSent: false`).
611
+
612
+ **Kill switches.** Settings → Agents (or `PUT /api/agent/acting {enabled: false}`) turns agent actions off for you;
613
+ an admin's `PUT /api/admin/agents/acting {enabled: false}` turns them off for the whole hub. Either one answers
614
+ every act verb `423 paused`; reading and messaging keep working (messaging has its own switch), and your own
615
+ actions and Decision answers still go through.
616
+
617
+ **Verbs.** The same act verbs are in the [CLI](#hub-cli) (`hub approve | deny | answer | prompt | stop`, `hub grant
618
+ list | show | create | revoke | request`), the OpenCode `hub` tool (verbs `approve`, `deny`, `answer`, `prompt`,
619
+ `stop`, with the session's act token), [`hub mcp`](#hub-mcp-mcp-server) (tools of the same names, as the app token)
620
+ and the SDK. Only you manage grants: the console (Settings → Agents) or the CLI with your device token
621
+ (`hub login --token <device token>`).
622
+
623
+ | Route | User | Session (act token) | App (`sessions:act`) |
624
+ |---|---|---|---|
625
+ | `POST /api/agent/sessions/:addr/approve {requestID, always?, why?}` | ✓ | under a grant | under a grant |
626
+ | `POST /api/agent/sessions/:addr/deny {requestID, why?}` | ✓ | under a grant | under a grant |
627
+ | `POST /api/agent/sessions/:addr/answer {formID, answer \| cancel: true, why?}` | ✓ | under a grant | under a grant |
628
+ | `POST /api/agent/sessions/:addr/prompt {text, delivery?, why?}` (users: `images?`) | ✓ | under a grant | under a grant |
629
+ | `POST /api/agent/sessions/:addr/stop {why?}` | ✓ | under a grant | under a grant |
630
+ | `POST /api/agent/grants` · `GET` · `GET`/`PUT`/`DELETE …/grants/:id` | ✓ | – | – |
631
+ | `POST /api/agent/grants/requests {grant, why}` | – | ✓ (any token) | `messages:send` |
632
+ | `GET` · `PUT /api/agent/acting {enabled}` | ✓ | – | – |
633
+ | `GET` · `PUT /api/admin/agents/acting {enabled}` | admins | – | – |
634
+ | `GET /api/agent/audit?limit=&before=` | ✓ | – | – |
635
+
636
+ **Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`) is in
637
+ your agent audit with who acted, the verb, the session and request, the grant, the reason and the outcome (`ok`,
638
+ `gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`); so are your answers to escalations, grant
639
+ requests and switch changes. Read it in Settings → Agents → Activity or with `GET /api/agent/audit` (newest first,
640
+ pages of up to 500; the last 2000 entries are kept); new entries stream to your console as SSE `{type: "agent",
641
+ entry}`. Admins see only a summary in the hub audit (the user, `agent.<verb>` and the actor kind; grant changes as
642
+ `grant.create`, `grant.update`, `grant.revoke` with the grant id), never sessions, requests, reasons, holders or
643
+ patterns.
644
+
645
+ ## Launches
646
+
647
+ A **launch** starts a session for you on one of your machines and ends it when it is done. On each machine that
648
+ should take launches, enrol it (the OpenCode plugin's `/hub` dialog), list where its repositories live in
649
+ `~/.config/hub/node.json` and run the node daemon:
650
+
651
+ ```sh
652
+ echo '{"workspaceRoots": ["/Users/you/projects"]}' > ~/.config/hub/node.json
653
+ hub node install # writes the launchd agent / systemd user unit and prints how to load it
654
+ ```
655
+
656
+ Then, from anywhere:
657
+
658
+ ```sh
659
+ hub launch --repo ~/projects/app --branch feat/search --name search --prompt "Add full-text search" --cleanup remove
660
+ hub launches list # queued → placing → provisioning → running → ended (idle, stopped, …)
661
+ ```
662
+
663
+ The node makes the git worktree `~/projects/app/.worktrees/feat/search`, the machine's OpenCode service starts the
664
+ session there as `@search`, the hub primes and prompts it, and once the session has worked and stays idle the
665
+ launch ends `idle` and the worktree is removed (the branch stays; nothing uncommitted is ever removed). Apps launch
666
+ with an app token (scope `sessions:launch`) under a grant with verb `launch`, retry safely with a `key`, tag
667
+ launches with labels and follow them with signed webhooks. `--harness claude-code` or `--harness codex` launches
668
+ those harnesses through their service spokes ([docs/launches.md](docs/launches.md#harnesses)). Everything, from the
669
+ spec's fields and limits to placement, caps, the node's git rules, webhook signatures and sandboxed launches in
670
+ OpenShell (`hub node openshell check`): [docs/launches.md](docs/launches.md).
671
+
672
+ ## hub CLI
673
+
674
+ `hub` (also installed as `hubctl`) reads, names, messages and acts on your sessions through the hub's [`/api/agent` API](#agent-identity-agent-api),
675
+ for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`$HUB_STATE_DIR/bin`, default
676
+ `~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
677
+ session `hub` just works. A launcher runs the CLI bundle shipped in this package (`packages/cli/dist/hub-cli.js`)
678
+ with the OpenCode binary itself (`BUN_BE_BUN=1 exec <opencode> hub-cli.js "$@"`), so neither bun nor node has to
679
+ be installed; without Bun it falls back to `node`. The launchers are rewritten only when their content changes.
680
+ When a standalone hubctl is installed as `~/.local/bin/hubctl` (what `hubctl setup` installs), the launchers run it
681
+ instead, so shells, services and MCP clients all use that one signed program. For your own terminals the plugin also
682
+ links `~/.local/bin/hub` to its `hub` launcher, with the setup's rule for `~/.local/bin`: only when nothing is there or
683
+ it is hub's own link, never when another `hub` is on PATH (then it prints where hub's is), and only from the default
684
+ state dir (not with `HUB_STATE_DIR`). `hubctl setup`'s `hub` → `hubctl` link is kept; `~/.local/bin/hubctl` is left to
685
+ `hubctl setup`. When `~/.local/bin` is not on PATH it prints one hint.
686
+
687
+ **Name clash:** GitHub's old `hub` CLI (github/hub) uses the same name. In hub sessions ours comes first on PATH;
688
+ elsewhere use `hubctl`, or call the bundle directly (`bun …/packages/cli/dist/hub-cli.js`).
689
+
690
+ ```
691
+ hub status [--json] machines → projects → sessions, and what needs attention
692
+ hub sessions [--project P] [--state S[,S]] [--machine M] [--label k=v]
693
+ hub show <addr> state, blockers, activity, summary
694
+ hub log <addr> [-n 20] newest timeline rows (1–200), printed oldest first
695
+ hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pings skipped)
696
+ hub whoami who this credential is
697
+ hub who [--project P] [--role R | @@role] named and role-holding sessions
698
+ hub name <@name> [--session <addr>]
699
+ hub role claim <role[.project]> [--exclusive] [--session <addr>]
700
+ hub role release <role[.project]> [--session <addr>]
701
+ hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
702
+ hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--all] [--no-wake] [--urgent]
703
+ hub reply <msgID> "text" [--no-wake] to the message's real sender, never via the role
704
+ hub decline <msgID> --why "reason"
705
+ hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
706
+ [--deadline 2h [--on-timeout <safe id>]] [--tier confirm|tap] [--about <addr>]... a Decision for the user
707
+ hub decisions list [--state open|answered|expired|closed] the Decisions you asked
708
+ hub inbox asks to answer, your open asks, recent messages, mutes
709
+ hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
710
+ hub approve <addr> <requestID> [--always] [--why "…"] answer a permission request (once, or always)
711
+ hub deny <addr> <requestID> [--why "…"] reject it; the reason reaches the harness
712
+ hub answer <addr> <formID> key=value… | --json '{…}' | --json - | --cancel [--why "…"]
713
+ hub prompt <addr> "text" [--delivery queue|steer] [--why "…"]
714
+ hub stop <addr> [--why "…"] stop its running turn
715
+ hub grant list · hub grant show <id> · hub grant revoke <id>
716
+ hub grant create --holder <h> --project <path>... --allow approve,deny,… [--permit <pattern>]... [--never <pattern>]...
717
+ [--expires 8h] [--max-per-hour N] [--allow-always] | --json '{…}'
718
+ hub grant request --project <path>... --allow approve,deny,… [--permission-allow <pattern>]... [--never <pattern>]...
719
+ [--holder <@@role you hold>] --why "…" (agents) ask the user for a grant
720
+ hub launch [--repo <path>] [--mode worktree|checkout|clone] [--branch B] [--base B] [--remote URL]
721
+ [--name <@name>] [--role R]... [--harness H] [--agent A] [--model provider/model]
722
+ [--runtime host|openshell [--profile P]] [--machine M] [--node-label k=v]... [--prefer]
723
+ [--label k=v]... [--prompt "text" | --prompt -] [--end idle|asked|never] [--timeout 4h]
724
+ [--cleanup keep|remove] [--key K] start a session on a hub node
725
+ hub launches list [--state S[,S]] [--label k=v]... · hub launches show <id> · hub launches stop <id>
726
+ hub node run the node daemon on this machine, until Ctrl-C or SIGTERM
727
+ hub node install [--dry-run] · hub node uninstall [--dry-run] · hub node status
728
+ hub claude [claude args…] Claude Code with the hub plugin (--plugin-dir; ~/.claude untouched)
729
+ hub claude-code install|uninstall [--dry-run] · hub claude-code status the Claude Code daemon as a service
730
+ hub codex [codex args…] Codex with its shared app-server daemon on (~/.codex untouched)
731
+ hub codex-spoke install|uninstall [--dry-run] · hub codex-spoke status the Codex spoke as a service
732
+ hub preset list · hub preset check <name|file>
733
+ hub preset run <name|file> [--socket <name> | --current] [--cwd <dir>] [--resume <id>] [--no-hub] [-- args…]
734
+ hub mcp an MCP server on stdio (see below)
735
+ hub login [--url URL] [--scope S]... [--name N] device code: approve it in the console, choose the scopes
736
+ hub login [--url URL] --token APP_TOKEN|- · hub logout
737
+ hub help · hub --version
738
+ ```
739
+
740
+ Addresses are `ses_…` (or an unambiguous prefix), `@name`, `@@role`, `@name.project` and `@me` (the user). Output is short text
741
+ (one session per line: name and ID, state, harness, current activity, blockers); `--json` prints the hub's answer
742
+ unchanged (for errors, the `{error: {code, message, hint?, …}}` envelope on stdout as well). Errors print the
743
+ message, any candidates or retry time, a missing feature, and the hint on stderr.
744
+
745
+ **Messaging.** `msg` and `ask` print what was stored and how it reached the recipient (`delivered` into a new or
746
+ the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline, or
747
+ `stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
748
+ `--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
749
+ read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
750
+ `--wait` take seconds or `30s`, `5m`, `2h`, `1d` (asks expire after 1 minute to 7 days, default 24 h). `ask --wait N`
751
+ 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
752
+ ask expires. A waiting `ask` exits 0 with the answer, 1 when declined (with the reason) or still open when the
753
+ wait is spent, and 8 when it expired; `--wait` cannot be combined with `--all`. `hub inbox` shows each open ask
754
+ with its `hub reply` / `hub decline` line. A recipient whose spoke cannot receive messages exits 7 (`unsupported`,
755
+ feature `deliver`: update the plugin there), messaging switched off by the user exits 5 (`paused`), sending too
756
+ much exits 6 (`rate_limited`), and answering an ask that is already answered or expired exits 8 (`gone`). Sessions
757
+ message with their session token; an app token needs the `messages:send` (and `messages:read` for `inbox`) scopes.
758
+
759
+ **Acting.** `approve`, `deny`, `answer`, `prompt` and `stop` act on another session: the user's credential always
760
+ may (its own sessions); an app token needs scope `sessions:act` **and** a grant; a session's shell token
761
+ (`HUB_SESSION_TOKEN`) never acts — agents act through the OpenCode `hub` tool, which holds the session's separate
762
+ act token. Outside the grant the hub answers `needs_user`: the CLI exits 4 and prints the evaluation (verdict,
763
+ tier, reason, the grants checked) and the Decision opened for the user, if any. `answer` takes `key=value` pairs (a
764
+ repeated key becomes a list), or the whole answer as JSON (`--json '{…}'`, or `--json -` from stdin; a bare
765
+ `--json` still means raw output), or `--cancel`. `--why` is recorded in the agent audit; for `deny` it is passed to
766
+ the harness. An already-answered request is `gone` (8), an offline machine `offline` (7).
767
+
768
+ **Grants.** `hub grant list | show | create | revoke` manage what a holder (`ses_…`, `@name` — pinned to that
769
+ session at creation —, a reserved `@@orchestrator…` role, or `app:<token id>`) may do: which verbs,
770
+ in which projects, which permission patterns (`--permit`, `--never`; never wins), for how long and how often. Only
771
+ the user manages grants (the console, or `hub login` with a device token): from a session's shell `create` and
772
+ `revoke` exit 5 without asking the hub, and any credential the hub refuses exits 5. Agents `hub grant request` one
773
+ instead (a session, or an app token with `messages:send`): the same flags, the holder defaulting to the requester,
774
+ and a `--why` the user reads; it prints the Decision it opened, and the user's answer comes back as a reply. See
775
+ [Agent actions and grants](#agent-actions-and-grants).
776
+
777
+ **Launching.** `hub launch` starts a session on a machine running `hub node` ([docs/launches.md](docs/launches.md)): the flags become the
778
+ body of `POST /api/agent/launches`, with only the given fields (the hub fills in its defaults: harness `opencode`,
779
+ mode `worktree` — which needs `--branch` and uses `<repo>/.worktrees/<branch>` —, runtime `host`, end `idle`,
780
+ cleanup `keep`). `--repo` defaults to the working directory and relative paths resolve against it; the path is the
781
+ repository on the node and must be under that node's workspace roots. `--label k=v` tags the launch for apps
782
+ (`hub launches list --label brain.task=abc` finds it again); `--node-label k=v` and `--machine` pick nodes, required
783
+ unless `--prefer`. `--prompt -` reads the first prompt from stdin. `--key` makes retries safe: the same key and
784
+ flags answer the existing launch (`… was already launched with key …`), the same key with other flags is invalid
785
+ (exit 2). It prints the launch and its state (`queued`, `placing`, `provisioning`, `running` with the session,
786
+ `ended` with an outcome: `idle`, `needs_user`, `failed`, `timeout`, `stopped`, `lost`); `hub launches show` adds the
787
+ workspace, steps and artifacts (branch, head, commits). `hub launches stop` ends a launch (`stopped`); one that
788
+ already ended exits 8 (`gone`). The user may always launch; an app token needs scope `sessions:launch` **and** a
789
+ grant with verb `launch` covering the repo (exit 5 otherwise; the kill switch pauses it); sessions launch only
790
+ through the OpenCode `hub` tool's act token. Apps and sessions see and stop only the launches they started.
791
+ Too many launches exit 6 with the retry time. Apps that register a webhook verify its `Hub-Signature` with the
792
+ SDK's `verifyWebhook({body, headers, secret})` (HMAC-SHA256 of `<t>.<raw body>`, compared in constant time,
793
+ timestamps older than 5 minutes refused). The spec's fields and limits, states, placement, caps and webhooks are in
794
+ [docs/launches.md](docs/launches.md).
795
+
796
+ **hub node.** `hub node run` is the per-machine daemon that prepares launch workspaces: it needs an enrolled
797
+ machine (it only reads the machine token in `~/.config/hub/config.json`) and `~/.config/hub/node.json` with at
798
+ least `{"workspaceRoots": ["/Users/you/projects"]}` (optional `labels`, `slots`, `memoryMB`); it exits 1 when the
799
+ machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. `hub node install` writes a
800
+ launchd agent (macOS, `~/Library/LaunchAgents/io.github.huynle.hub-node.plist`) or a systemd user unit (Linux,
801
+ `~/.config/systemd/user/hub-node.service`) from the templates in `scripts/`, with the runtime and CLI entry running
802
+ the command (so `hubctl node install` from a hub session runs the bundle with the OpenCode binary) and
803
+ `HUB_SPOKE_CONFIG` / `XDG_CONFIG_HOME` when set, and **prints** the `launchctl` / `systemctl` commands that load it;
804
+ hub never runs them. `--dry-run` prints the file without writing it; a second install says it is up to date (or
805
+ updates it), and warns when the node would not run yet. `hub node uninstall` removes the file and prints the
806
+ commands that stop the service; `hub node status` shows the file (up to date or not), the enrolment (never the
807
+ token), node.json and the commands to inspect the service. What the node does to your repositories (worktrees under
808
+ `<repo>/.worktrees/<branch>`, cleanup that never forces) is in [docs/launches.md](docs/launches.md#hub-node).
809
+
810
+ **Other harnesses.** `hub claude` runs Claude Code with the hub's plugin loaded through `--plugin-dir` and starts the
811
+ per-user Claude Code daemon (`hub claude-code daemon`) when none runs; `hub codex` runs Codex with its shared
812
+ app-server daemon turned on for that run and starts the per-user Codex spoke (`hub codex-spoke serve`). Both pass
813
+ every other argument and the exit code through, never write `~/.claude` or `~/.codex`, and run the harness without
814
+ the hub (saying why) when the machine is not enrolled. `hub claude-code install` and `hub codex-spoke install` write
815
+ a launchd agent or systemd user unit for those processes, like `hub node install` (printed, never loaded), so
816
+ `claude-code` and `codex` launches can be placed without a wrapper run. `hub preset list | check | run` drives a
817
+ harness without a spoke through a pane on a private tmux server. Settings, the op matrix and the limits:
818
+ [docs/harnesses.md](docs/harnesses.md).
819
+
820
+ **Credentials.** `HUB_URL` + `HUB_SESSION_TOKEN` (the spoke sets them in each session's shells; the CLI then
821
+ acts as that session) win over the login file. Outside a session, `hub login --url https://hub.example` stores
822
+ an app token in `~/.config/hub/cli.json` (`$XDG_CONFIG_HOME` respected; mode 0600) after checking it with
823
+ `whoami`; the token is read from stdin (or prompted) unless `--token` is given — prefer stdin, since arguments are
824
+ visible to other local users in `ps`. App tokens are created by a signed-in user with
825
+ `POST /api/agent/app-tokens {label?, scopes?}` (scopes `sessions:read`, `messages:send`, `messages:read`; shown once) and revoked with
826
+ `DELETE /api/agent/app-tokens/:id`; `hub logout` only deletes the file. App tokens can read but not name or
827
+ claim roles (writes are for sessions and users). `whoami` and `status` warn on stderr when `OPENCODE_SESSION_ID`
828
+ is set and the credential is not that session. The CLI removes `BUN_BE_BUN` from its own environment.
829
+
830
+ | Exit | When |
831
+ |---|---|
832
+ | 0 | ok |
833
+ | 1 | other errors, network failures, a watch stream the hub closed, an `ask --wait` declined or not yet answered |
834
+ | 2 | usage (unknown command or option, missing argument; also the hub's `invalid`) |
835
+ | 3 | `not_found`, `ambiguous` (candidates listed), `name_taken` |
836
+ | 4 | `needs_user` |
837
+ | 5 | `forbidden`, `paused` |
838
+ | 6 | `rate_limited` (with `retryAfter`) |
839
+ | 7 | `unsupported`, `offline` |
840
+ | 8 | `gone`; an `ask --wait` that expired |
841
+ | 9 | `unauthorized`, `token_expired` (a session token: start a new shell), or not logged in |
842
+
843
+ ### hub mcp (MCP server)
844
+
845
+ `hub mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio for MCP clients outside
846
+ OpenCode (Claude Desktop, Cursor, other agents). It speaks JSON-RPC 2.0, one message per line (MCP's stdio
847
+ transport; protocol revisions `2025-06-18`, `2025-03-26` and `2024-11-05`), and answers `initialize`, `ping`,
848
+ `tools/list` and `tools/call`; notifications (`notifications/initialized`, `notifications/cancelled`, which aborts
849
+ the call) get no answer, other methods answer `-32601`. Its tools are the CLI's verbs: `whoami`, `status`,
850
+ `sessions`, `show`, `log`, `who`, `archive`, `unarchive`, `pin`, `unpin` (`addr`), `archive_inactive`
851
+ (`preview`, `inactiveFor`, `limit`, then `previewToken`, `confirm`), `msg`, `ask`, `reply`, `decline`, `inbox`, `message_show` (`id`, `wait`),
852
+ `mute` and `unmute` (`addr`; agent-message mutes, not console notifications), `decisions` (`state`) and
853
+ `decision_show` (`id`) (reading the Decisions you asked; no tool answers one, only the user does), and
854
+ `webhook_create` (`url`, `events`, `label`), `webhooks`, `webhook_show` (`id`) and `webhook_delete` (`id`). Webhooks
855
+ carry the same canonical compact events as `events`; their signing secret appears only in `webhook_create`. The act verbs are `approve`, `deny`,
856
+ `answer` (`answer` object or `cancel`), `prompt` (`delivery`) and `stop`, and the launch tools `launch` (`repo`
857
+ required and absolute; `mode`, `branch`, `base`, `remote`, `key`, `name`, `roles`, `harness`, `agent`, `model`,
858
+ `runtime`, `profile`, `machine`, `nodeLabels` and `labels` objects, `prefer`, `prompt`, `end`, `timeout`,
859
+ `cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`), with the same arguments
860
+ (`to`, `text`, `addr`, `id`, `requestID`, `formID`, `why`, `n`, `wake`, `urgent`, `all`, `project`, `role`, …). The act
861
+ tools need an app token with scope `sessions:act` and a grant, the launch tools scope `sessions:launch` and a
862
+ launch grant; `needs_user` results include the evaluation. `ask` takes `wait` of at
863
+ most 60 s (one long poll; a longer wait is cut to 60) and `expires` in seconds or as `2h`; `ask @me` reports the
864
+ Decision it opened. Answers are the CLI's
865
+ short text; messages and answers from other sessions come in the hub's envelope and are marked as not from the
866
+ user. Hub errors are tool results with `isError: true` and the hub's `code: message` (plus candidates and hint),
867
+ never protocol errors. Only protocol frames go to stdout; the server ends when stdin closes (after answering the
868
+ calls in flight) or on SIGINT/SIGTERM.
869
+
870
+ ```json
871
+ { "mcpServers": { "hub": { "command": "/home/you/.local/state/hub/bin/hubctl", "args": ["mcp"] } } }
872
+ ```
873
+
874
+ (the launcher in the hub bin dir, `$HUB_STATE_DIR/bin`; just `hubctl` when that dir is on the client's PATH).
875
+
876
+ **Identity.** The server acts as the CLI's credential, read for each call: `HUB_URL` + `HUB_SESSION_TOKEN` when
877
+ set, else the `hub login` file. MCP clients start servers without a hub session's shell environment, so in practice
878
+ it acts as the **app token** you logged in with (needs `sessions:read`, `messages:send` and `messages:read`), not
879
+ as an OpenCode session: messages are sent from the app, and replies to them land in its inbox (the `inbox` tool),
880
+ not in a session. App tokens can archive, unarchive and name sessions with `sessions:manage`, and claim or release roles with it plus a `roles` grant. Inside OpenCode, agents use the plugin's
881
+ `hub` tool instead, which acts as the calling session.
882
+
883
+ #### Over HTTP (/mcp)
884
+
885
+ The hub serves the same tools at **`POST /mcp`** ([Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http)),
886
+ for MCP clients and gateways that take a remote server URL. It is stateless: one JSON-RPC message per POST, answered
887
+ as `application/json` (no SSE stream, no `Mcp-Session-Id`); notifications and responses get `202` with no body;
888
+ batches answer `-32600`; `GET`/`DELETE` answer `405` (`Allow: POST`). `initialize` also returns short
889
+ `instructions`. Requests need `Content-Type: application/json`, an `Accept` that allows `application/json` (or
890
+ none), a body of at most 1 MiB (else `413`) and, when sent, a supported `MCP-Protocol-Version` (else `400`). A
891
+ browser `Origin` other than the hub's own is refused (`403`); requests without one (servers, gateways) are fine.
892
+
893
+ **Auth.** `Authorization: Bearer <token>`: any credential the agent API takes, in practice an **app token**
894
+ (Settings → Agents, or `POST /api/agent/app-tokens`) with the scopes the tools need: `sessions:read` (reading), `sessions:manage` (archive/unarchive, name; roles also need a `roles` grant), `events:subscribe` (events),
895
+ `messages:send` and `messages:read` (messaging), `sessions:act` plus a grant (act tools), `sessions:launch` plus a
896
+ launch grant (launch tools). Device, session and act tokens work too. No or a bad token answers `401` with
897
+ `WWW-Authenticate: Bearer realm="hub"` (no OAuth discovery). The tools call `/api/agent` as that token, inside the
898
+ hub process: scopes, grants, tenancy, kill switches and the agent audit apply as on the HTTP API, and a scope the
899
+ token lacks is an `isError` tool result. Cookies never authenticate `/mcp`.
900
+
901
+ Add it to an MCP client or gateway as a remote (HTTP) server with bearer auth, one token per user:
902
+
903
+ ```json
904
+ { "mcpServers": { "hub": { "type": "http", "url": "https://hub.example.com/mcp", "headers": { "Authorization": "Bearer hat_…" } } } }
905
+ ```
906
+
907
+ (an MCP gateway: URL `https://<hub>/mcp`, auth kind bearer, per-user credential: each user pastes their own app token).
908
+
204
909
  ## Spoke (OpenCode plugin)
205
910
 
206
911
  The plugin is a thin client ("spoke") of the hub. Every OpenCode process that loads
@@ -208,6 +913,13 @@ it opens **one** outbound WebSocket (`/spoke/ws`, protocol in `src/protocol.ts`)
208
913
  knows, forwards their events, and runs turns, stops and permission replies on the hub's request.
209
914
  The web app, speech and phones live on the hub.
210
915
 
916
+ Besides the session, tool and permission events, a spoke forwards `session.status` (busy/retry/idle) and the
917
+ shells and terminals sessions start (`session.shell.started/ended`, `shell.created/exited/deleted`,
918
+ `pty.created/updated/exited/deleted`) in compact form: id, session, command (≤ 200 characters), status and exit
919
+ code; never output, files or environment. When it connects (and again after its hub connection or OpenCode
920
+ event stream comes back) it asks its own server what runs now (`session.active` and `shell.list`) and sends a
921
+ `session.seed` event per running session, so a session that was already running shows as working at once.
922
+
211
923
  | Entry | File | Role |
212
924
  |---|---|---|
213
925
  | 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 +1009,9 @@ work to a background subagent. Routine workers inherit the fast voice model; cod
297
1009
  architecture and risky work use the configured full work model. Successful workers finish with a short
298
1010
  speech-ready result that the hub reads directly, without waiting for another voice-agent summary. You
299
1011
  can keep talking while work runs; related follow-ups continue the active worker and unrelated tasks start
300
- a fresh one.
1012
+ a fresh one. Several independent requests in one breath ("check the deploy and update the changelog") start
1013
+ one background worker each, in parallel, in the same turn; each worker gets a short name ("Deploy check") that
1014
+ the task board and announcements use.
301
1015
 
302
1016
  The phone composer exposes separate **Voice** and **Worker** model controls for each session. Each picker
303
1017
  lists the connected OpenCode process's available models and variants. Choices are saved per user and
@@ -305,7 +1019,11 @@ session, apply to the next turn or newly started worker, and fall back to the ma
305
1019
  `workModel` when unset.
306
1020
 
307
1021
  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
1022
+ between messages as compact expandable rows. Rows keep the session's order, never wall-clock order: history
1023
+ rows carry the spoke's `pos` (message order, then part order), live rows go after the history loaded when they
1024
+ arrived (in arrival order) and are replaced in place by their stored row, and a subagent row stands where the
1025
+ tool call that spawned it is (timeline tool rows of `subagent`/`task` carry `childSessionID`; a subagent whose
1026
+ spawn is not loaded yet shows at the top of the loaded history). Reopening a session pages its projected message history from
309
1027
  OpenCode. Expanding a tool fetches its complete input/output from the owning OpenCode process; the hub does
310
1028
  not persist those potentially sensitive details.
311
1029
 
@@ -320,8 +1038,31 @@ not persist those potentially sensitive details.
320
1038
  agent exists, the session keeps its agent and the `context` hook adds the orchestrator prompt instead.
321
1039
  - The delegate agent (`subagent` in the config) is added per request by the `context` hook, so changing
322
1040
  it needs no restart.
1041
+ - **Task board**: the `context` hook also gives the voice agent (and a conversation on another agent) a live
1042
+ list of the workers it started (`src/spoke/taskboard.ts`, from this process's events): title, sessionID,
1043
+ state (working, blocked with what it waits for, done with its latest text, failed with the reason, stopped)
1044
+ and for how long, at most 8 (live ones first) of at most 200 characters each. So "what's running?" or "how's
1045
+ the deploy going?" needs no tool call. A session without workers gets no board.
1046
+ - **Stopping one task by voice**: the plugin adds a tool `hub_task_stop {sessionID} | {all: true}`. The voice
1047
+ agent calls it for "cancel the deploy one" (that worker and whatever runs below it are interrupted, deepest
1048
+ first) or "stop all the workers" (every running worker of the session). It only reaches sessions below the
1049
+ calling session (never the caller itself, its siblings or another session's workers), so the voice agent
1050
+ keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
1051
+ interrupt the root turn at once.
1052
+ - **Messaging other sessions**: the plugin adds a tool `hub {verb, …}` with the verbs `msg`, `ask`, `reply`,
1053
+ `decline`, `inbox`, `who`, `status` and `whoami` (`src/spoke/hub-tool.ts`). It acts as the calling session
1054
+ with that session's own hub token (the token never appears in the tool's output). `ask` with `wait` (seconds,
1055
+ at most 600) blocks for the answer until the tool call is stopped; without it the answer arrives later as a hub
1056
+ message. Messages from other sessions are shown in the hub's envelope and are not from the user.
323
1057
  - 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.
1058
+ child is emitted as root-session speech; interruptions and stale partial results are not. **Announcements**
1059
+ (hub, `src/central/registry.ts`): while another worker of the session runs or waits, or one was announced in
1060
+ the last minute, a result starts with the task's title ("Deploy check finished: …"); a result the voice agent
1061
+ already said in the last two minutes is not read again. A failed worker is announced once with its reason
1062
+ ("Lint failed: rate limit exceeded."), a worker's permission request or question once per request ("Cleanup
1063
+ needs your permission.", "… has a question."); a stopped worker is not. Announcements are ordinary `say`s
1064
+ without `seq` (`messageID` `announce:failed:<session>`, `announce:permission:<id>`, `announce:form:<id>`),
1065
+ so the phone queues them after the sentence playing and never cuts one off.
325
1066
  - Server ops: `steer` sends the text with `delivery: "steer"` (metadata `{source: "hub", mode:
326
1067
  "conversation"}`, or `mode: "remote"` for a session not shared with Voice; 0.5 wrote `source:
327
1068
  "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 +1089,10 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
348
1089
  | `HUB_AUTH` | `pairing` | `oidc` for several users signing in with Google; see [Multi-user](#multi-user-sign-in-with-google) |
349
1090
  | `HUB_HOST` / `HUB_PORT` | `127.0.0.1` / `4097` | listen address |
350
1091
  | `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) |
1092
+ | `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 |
1093
+ | `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 |
1094
+ | `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) |
1095
+ | `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
352
1096
  | `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
353
1097
  | `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
354
1098
  | `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
@@ -374,7 +1118,29 @@ the Docker image, npm package and binaries ship the build). One store fed by the
374
1118
  - **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
375
1119
  terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
376
1120
  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).
1121
+ (rename, pin, archive, stop, Allow/Disallow Voice).
1122
+ - **Archive**: **Archive** in a session's `⋯` menu takes it out of the table, the sidebar's groups, Recents and
1123
+ Running, on all your devices (with **Undo**). Nothing is deleted and OpenCode's history is untouched:
1124
+ archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive
1125
+ will map to it). The **Archived** chip on `/` lists archived sessions with **Unarchive**. One exception: an
1126
+ archived session that needs you (a permission or question) also shows under **Needs you** (sidebar and the
1127
+ Needs you chip), so a question is never missed. In the table, tick rows (or the header box for all shown
1128
+ rows) and press **Archive** (or **Unarchive** under Archived); **Archive inactive…** archives every session
1129
+ without activity for more than N days (30 by default; not running, pinned or open in a terminal) after a
1130
+ confirmation with the count. Sessions archived in OpenCode itself (`time.archived`) never reach the hub.
1131
+ Sessions hidden with an earlier version are archived now (same stored list, `hiddenSessions`).
1132
+ - **Rename**: **Rename…** in a session's `⋯` menu (table row, sidebar row, phone list, the session's title
1133
+ menu), or a click on the title in the session's top bar, turns the title into a text field with the title
1134
+ selected: Enter (or clicking away) saves, Escape cancels. The new title shows at once and is set in
1135
+ OpenCode itself (`POST /api/sessions/:id/rename`), so terminals see it too; if that fails the old title
1136
+ comes back with a message.
1137
+ - **Auto-name**: **Auto-name** in the same `⋯` menus, or the ✨ button next to the rename field, has OpenCode
1138
+ name the session with its Title agent (what `/rename` without a title does in the terminal; the model is
1139
+ `agents.title.model`), from the first request and the recent conversation. The title shows "Naming…" until
1140
+ OpenCode answers, then a toast says "Renamed to …" or "OpenCode kept the title". Needs this plugin version
1141
+ in the session's OpenCode process (older ones answer "Update the Hub plugin to use Auto-name") and a process
1142
+ that can reach its own server: the background service, or `opencode serve --port …` with
1143
+ `OPENCODE_SERVER_PASSWORD` (a TUI's own `serve --stdio` server cannot; use `/rename` there).
378
1144
  - **`/`**: All sessions as a sortable, filterable table (a list on phones); filters and sort are kept per device.
379
1145
  There is no Voice column or Voice filter: the live Voice session gets the green dot before its title, and the
380
1146
  row's `⋯` menu has **Allow Voice** / **Disallow Voice**.
@@ -440,8 +1206,8 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
440
1206
  group: New session here, Pin / Unpin workspace, Hide / Unhide workspace (and Collapse / Expand in
441
1207
  Recent). Hiding a workspace hides the sessions in it: a Herdr workspace (`herdr:<id>`), a project
442
1208
  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
1209
+ - **Synced view settings**: pins, archived sessions and hidden workspaces are per user on the hub (`GET`/`PUT /api/prefs/view`), shown at
1210
+ once, reverted with a message if the hub refuses, with Undo after an archive. Other devices update live
445
1211
  (SSE `prefs`). Pins from 0.3 (stored on the device) are moved into them once. With an older hub
446
1212
  without `/api/prefs/view`, they stay on the device. Open/closed groups and the filter stay per device.
447
1213
  - **Voice off** sessions (`exposed: false`, mode `session`) open, show their status and live activity, and
@@ -461,16 +1227,40 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
461
1227
  failed, the session still exists and the hub's `warning` is shown. 409 (background service not
462
1228
  connected) is shown under the composer.
463
1229
 
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
1230
+ **Notifications** (web push, VAPID keys generated once and stored in the state file). Each device picks each
1231
+ kind it wants (Settings → Notifications, grouped, with a one-line description each and a "You'll get: …"
1232
+ summary); [docs/notifications.md](docs/notifications.md) lists every kind, its trigger and what is not notified:
1233
+
1234
+ - **Needs you**: a **question**, a **permission** (on by default).
1235
+ - **Session**: **All done** (on): the root session's last turn succeeded and the whole tree is settled (no
1236
+ subagent or shell job running, nothing waiting for an answer, no hub turn) for 15 s, once per run;
1237
+ **Failed** (on, with the reason); **Turn finished** (every root turn, 0.7's "finished": off, noisy because
1238
+ each background subagent that completes starts a short continuation turn); **Stopped** (shutdown,
1239
+ inactivity) and **Retrying** (provider retries), off.
1240
+ - **Subagents**: each subagent **finished** / **failed** (off). **Scripts and tools**: a background shell or
1241
+ terminal **exited 0** / **failed** (non-zero, killed, timed out), a **long tool** (≥ 30 s) ended (off).
1242
+ - **Voice**: spoken replies while no device shows the session (`say`) and **hand-offs** from the terminal (on).
1243
+ - **Activity digest** (off, below).
1244
+
1245
+ Titles read "*session* · *kind*" ("KVM Rewrite · All done", "KVM Rewrite · Subagent S3 macros finished",
1246
+ "KVM Rewrite · Script exited 1"). Nothing but questions, permissions, hand-offs and spoken replies is sent
1247
+ while a device shows the session. Stored 0.7 prefs migrate on start: `finished` becomes All done (not every
1248
+ turn), the rest is kept and the new kinds start off (0.5.0's single "blocked" toggle still becomes question +
1249
+ permission). Optional **quiet hours** per device (local start/end time in the browser's time
471
1250
  zone) hold everything except hand-offs; **Mute notifications** in a session's ⋯ menu silences one session
472
1251
  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.
1252
+ (Voice replies: 10 s; subagents, scripts and long tools: per subagent, job or call); expired subscriptions
1253
+ (404/410) are pruned.
1254
+
1255
+ **Activity updates** (pref `activity`, off by default on every device; Settings → Notifications shows a hint
1256
+ until it is on): to know what happens without keeping the screen on. Steps of interest in a session tree — a
1257
+ subagent started or ended, a tool that took 30 s or more, a background shell or terminal that ended (with its
1258
+ exit code: "bun run build exited 0", "was killed", "timed out"), the session going idle — become one
1259
+ notification per session, tagged per session so each replaces the last quietly (no new sound, normal
1260
+ urgency, 5-minute TTL). The first step after a quiet spell goes out at once; later ones are collected until the
1261
+ device's interval ends (Settings: 1–60 minutes, default 5) and sent together ("3 tools · explore finished ·
1262
+ Finished"). Short tools are only counted. Quiet hours and muted sessions apply; nothing is sent while a
1263
+ device shows the session.
474
1264
 
475
1265
  - **Status per device**: Settings → Notifications shows the browser permission, whether the service worker
476
1266
  is registered and whether this hub holds this browser's subscription. "Allowed, but this device is not
@@ -478,12 +1268,14 @@ for you on every device (hand-offs still come through). At most one per session
478
1268
  notifications on this device** fixes it. When permission is granted, the app re-subscribes by itself on
479
1269
  load if the hub lost the subscription, and the service worker re-subscribes when the browser rotates it
480
1270
  (`pushsubscriptionchange`). **Send test notification** sends one to this device only and shows the result
481
- (sent, or the push service's status).
1271
+ (sent, or the push service's status); pick a kind next to it to see how that kind looks.
482
1272
  - **Delivery**: questions, permissions and failures are sent with `urgency: high` and a TTL of one hour so a
483
1273
  phone in doze gets them; they stay on screen until handled (`requireInteraction`), vibrate, and replace the
484
1274
  previous one of the same session and kind. A permission notification asks the hub (with the device's
485
1275
  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?}`.
1276
+ the payload itself never carries it. Payloads are `{kind, sessionID, title, body, requestID?, test?}`.
1277
+ Failures (and failed subagents and scripts) vibrate; progress kinds (turn, retrying, subagent and script
1278
+ finished, long tool, activity) replace the previous one of the session quietly.
487
1279
  - **Android (Chrome)**, for notifications that arrive with the screen off: open the public URL, sign in or
488
1280
  pair, then Chrome's menu → **Install app** (or Add to Home screen) and use the installed app; Settings →
489
1281
  Notifications → **Enable notifications on this device** and **Send test notification**. In Android
@@ -562,6 +1354,16 @@ disconnects their machines at once.
562
1354
  login links from `/hub` belong to the machine's owner. The bootstrap pairing code and the local
563
1355
  first-machine auto-approve exist only in `pairing` mode.
564
1356
 
1357
+ **Native apps** link from Settings → Devices → **Add app**: the dialog shows a QR code of
1358
+ `hubapp://link?hub=<public URL>&code=<code>` (and the link itself), with a 5-minute countdown. The code is
1359
+ single use, 256-bit, belongs to the user who opened the dialog (who must still be active when it is redeemed)
1360
+ and works only for linking: `/login` and `/api/pair` refuse it. The app calls `POST /api/devices/redeem
1361
+ {code, deviceName?, publicKey?}` (no cookie, and no `Origin` needed: the code is the only credential) and gets
1362
+ `{device, token}`; it keeps `token` and sends it as `Authorization: Bearer <device token>`. The device appears
1363
+ in the list (audit `device.pair`, "app link"); revoking it invalidates the token. `deviceName` is cleaned
1364
+ (control characters dropped, at most 64 characters, "App" when empty); `publicKey` (PEM or base64, at most
1365
+ 4096 characters) is stored with the device for later strong approvals.
1366
+
565
1367
  **Speech**: each user can set their own provider, models, voice and key (Settings → Speech); fields they
566
1368
  leave empty come from the server default (admins: Settings → Speech → Server default), then `hub.json`,
567
1369
  then the env. A user's key is only ever shown (masked) to that user.
@@ -601,8 +1403,8 @@ unchanged sessions frame does not re-broadcast the catalog.
601
1403
  |---|---|---|
602
1404
  | `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
1405
  | `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) |
1406
+ | `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) |
1407
+ | `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
1408
 
607
1409
  For a session reported only with `exposed: false`, `utterance` and `conversation` answer
608
1410
  `403 {error: "Voice is not enabled for this session", code: "not_exposed"}`; inspecting and controlling it
@@ -613,7 +1415,12 @@ session.
613
1415
 
614
1416
  ### Hub API
615
1417
 
616
- All routes need the device cookie; POSTs also need a same-origin `Origin` (or `Referer`). Every
1418
+ All routes need the device cookie; POSTs also need a same-origin `Origin` (or `Referer`). Native apps and
1419
+ other non-browser clients send the same device secret as `Authorization: Bearer <device token>` instead: same
1420
+ powers, no same-origin check (a request with both a cookie and a bearer token is refused with
1421
+ `400 {"error": …, "code": "invalid"}`; an unknown bearer token is `401 {"code": "unauthorized"}`). Other bearer
1422
+ tokens (session and app tokens) are refused on these routes (`403 {"code": "forbidden"}`); they are for
1423
+ `/api/agent/*` only. Every
617
1424
  `/api/sessions/:id…` route answers `404 {"error": "Not found"}` for a session that none of the caller's
618
1425
  machines reports (since 0.3.0; before, a session nobody reported was 409), so another user's session looks
619
1426
  exactly like an unknown one. For a loopback
@@ -635,21 +1442,39 @@ hub sends it `{op: "conversation", on: true}` once. Deleting the session clears
635
1442
 
636
1443
  SSE (`/api/events`) adds:
637
1444
 
1445
+ - Each session's status tree entry may carry `jobs` (`[{id, kind: "shell" | "pty", command?, status:
1446
+ "running" | "exited" | "timeout" | "killed", exit?, background?, started?, ended?}]`: running ones plus the
1447
+ last 5 that ended) and `retry` (`{attempt?, message?}`, with `detail` "Retrying: …"). A session is
1448
+ `working` while its run, a busy/retry `session.status` or a running job lasts. The console shows a job count
1449
+ next to the status (table, sidebar), "Retrying …" or "· 1 job" in the session header, and a **Jobs** card in
1450
+ the rail (command, running time, exit code for a minute after it ends).
1451
+
638
1452
  - `{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.
1453
+ - `{type: "say", sessionID, messageID, ordinal, seq, text}`: **root** text in conversation mode is spoken
1454
+ while it is written. The hub cuts the streamed `session.text.delta`s of each text part (`messageID`,
1455
+ `ordinal`) into sentences (`src/central/say-stream.ts`: sentence ends followed by the next sentence, line
1456
+ breaks, whole fenced code blocks, abbreviations/decimals/file names kept together, runs over 300 chars cut
1457
+ at a comma) and sends one `say` per completed sentence right after its delta, `seq` counting from 0 per text
1458
+ part; `session.text.ended` (authoritative: missed deltas are recovered) flushes the rest. Sentences are
1459
+ raw slices of the text: `/api/speak` makes each one speakable. One process's deltas are used per text part
1460
+ and the end is handled once, so each sentence is sent once; an interrupted or failed run, or conversation
1461
+ mode turning off, drops the unspoken rest. A successful subagent's final message is spoken through its
1462
+ root as before: one `say` per text part without `seq`. Blank text is skipped.
642
1463
  - `{type: "heard", sessionID, inboxID}` when OpenCode delivers an utterance's inbox item (also when the
643
1464
  delivery event arrives before the steer result).
644
1465
  - `{type: "activity", sessionID, rootID, item}` when an activity item is created or changes state, for
645
1466
  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
1467
+ tool?, summary?, background?, childSessionID?, state: "running" | "done" | "failed", error?, at}`
1468
+ (`childSessionID`: the session a subagent tool started, once the tool reports it); `sessionID` is where it
647
1469
  happened (a subagent session maps to its root `rootID`). The hub keeps the 30 most recent items per
648
1470
  root (in memory) and returns them, newest last, as `activity` in `GET /api/sessions/:id`. Spokes forward
649
1471
  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
1472
+ input (command, path, pattern…), ok/error; never file contents or outputs. A subagent tool's
1473
+ `session.tool.progress` goes out only as `session.tool.called {id, childSessionID}` (a type every hub
1474
+ accepts); other progress is dropped. Thinking items close on
651
1475
  `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,
1476
+ - `{type: "delta", sessionID, rootID, messageID?, ordinal?, text}` (assistant text; `ordinal`: which text part of
1477
+ the message, as OpenCode numbers them) and `{type: "reasoning", sessionID,
653
1478
  rootID, messageID?, delta}` (reasoning text; spokes cut each delta to 2000 chars and never forward the full
654
1479
  text of `session.reasoning.ended`) for root **and** subagent sessions. Per session they are coalesced to at
655
1480
  most one send per 100 ms (the first delta goes out at once, later ones in the window are joined); anything
@@ -657,11 +1482,13 @@ SSE (`/api/events`) adds:
657
1482
  - `status` entries of working sessions carry `since` (ms epoch the current run started), also in the catalog
658
1483
  and `GET /api/sessions/:id`.
659
1484
 
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.
1485
+ Pushes depend on presence: while the session is watched, run notifications (all done, turn, failed, …) are
1486
+ 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
1487
+ 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
1488
+ 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
1489
+ `HUB_MAX_SPEECH_CHARS` per call, so the phone sends sentence-sized chunks. `/api/speak` streams the provider's MP3 through as it
1490
+ arrives (chunked; nothing is buffered on the hub, and a phone that cancels aborts the upstream request), so playback
1491
+ can start before synthesis ends; provider errors still answer 502 before any audio.
665
1492
 
666
1493
  ### Speech settings
667
1494
 
@@ -671,8 +1498,8 @@ STT and TTS each use a provider configured from the phone (Settings → Speech)
671
1498
  provider than `hub.json`, the file's section is ignored for that direction; when it picks another base
672
1499
  URL, the file's key is not sent there. Providers:
673
1500
 
674
- - `openrouter` (default, base `https://openrouter.ai/api/v1`): STT is JSON with base64 `input_audio`,
675
- TTS is `POST /audio/speech`.
1501
+ - `openrouter` (default, base `https://openrouter.ai/api/v1`): STT is an OpenAI-style multipart upload
1502
+ (`file`, `model`, `language?`: the audio goes up as binary, not base64 JSON), TTS is `POST /audio/speech`.
676
1503
  - `openai-compatible` (`baseURL` required: `https://…`, or `http://localhost` / `127.0.0.1` for local
677
1504
  servers): STT is multipart `POST {baseURL}/audio/transcriptions` (`file`, `model`, `language?`), TTS is
678
1505
  `POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}`. No env key.
@@ -691,27 +1518,62 @@ Writes need the same-origin `Origin`. Provider failures are 502 with the provide
691
1518
  ### Conversation mode (phone)
692
1519
 
693
1520
  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
1521
+ (mono, with the browser's echo cancellation, noise suppression and auto gain on), then calls `POST …/conversation {on:true}`, keeps
695
1522
  the screen awake (Screen Wake Lock, taken again when the page becomes visible) and shows a level ring
696
1523
  around the mic with the current state: Listening, Hearing you, Transcribing, Sent ✓ and Speaking. Tap
697
1524
  it again, say "pause listening", or leave the page to turn it off. Leaving the page calls
698
1525
  `{on:false}` with `keepalive`. While conversation mode is off, tap-to-talk works as before.
699
1526
 
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
1527
+ - **Speech detection** (`web/src/core/speech-vad.ts`; ~20 ms frames from `web/public/vad-worklet.js`, with a
1528
+ ScriptProcessor fallback): on-device [Silero VAD](https://github.com/snakers4/silero-vad) v5 (MIT) through
1529
+ onnxruntime-web (MIT, wasm, single-threaded, so no COOP/COEP headers are needed) in a Web Worker
1530
+ (`web/src/voice/silero-worker.ts`). The mic audio is resampled to 16 kHz and cut into 32 ms chunks; each
1531
+ chunk's speech probability starts speech at ≥ 0.6 sustained for 250 ms (most of the onset voiced, so
1532
+ isolated clicks never add up) and ends it after 350 ms below 0.35. Typing, a fan, clatter, beeps and hum
1533
+ score low; speech scores high at any loudness. An energy detector stays as a cheap pre-gate (chunks within
1534
+ 3 dB of the adaptive noise floor skip the model) and as the fallback while the model loads, if it fails to
1535
+ load (logged once in the console) or stalls: noise floor that falls fast and rises slowly, threshold 10 dB
1536
+ above it with 4 dB of hysteresis, 300 ms to count. 300 ms of audio before the onset is kept. The model
1537
+ (2.3 MB) and the wasm (12 MB; 2.3 MB with Brotli) load when Voice mode first starts, as hashed static
1538
+ assets the hub serves with `cache-control: immutable` (and compressed when the browser accepts it), outside
1539
+ the main JS chunk; the Voice bar's tooltip says which detector runs and its time per chunk (about 1 ms).
1540
+ - **Mic sensitivity** (Settings → Voice, per device, Normal by default, applies at once): Low needs ≥ 0.75 for
1541
+ 350 ms (ends below 0.45; pre-gate 6 dB; energy fallback 13 dB / 400 ms), Normal as above, High ≥ 0.45 for
1542
+ 180 ms (ends below 0.25; no pre-gate margin; energy fallback 7 dB / 250 ms).
1543
+ - **Echo guard**: while a reply or a progress cue plays, and 400 ms after, a barge-in needs more: the model's
1544
+ probability ≥ 0.85 (Low 0.92, High 0.75) and 8 dB more energy. The browser's echo cancellation removes most
1545
+ of the reply from the mic first.
1546
+ - **Noise transcripts** (`web/src/core/junk-transcript.ts`): what speech-to-text makes of noise is dropped
1547
+ before it reaches the agent and shown dim in the Voice bar for 3 s ("ignored: “Thank you.”"): empty or
1548
+ punctuation-only text, sound tags ("[Music]", "(applause)", "♪"), Whisper's stock phrases ("Thank you.",
1549
+ "you", "Bye.", "Thanks for watching!", "Subtitles by …", "Amara.org" …), one word repeated, and one or two
1550
+ words from under 400 ms of speech unless they hold a command or answer word ("yes", "no", "okay", "stop",
1551
+ "cancel", "wait", "go ahead", "cancel that" …). With a Whisper model the hub also asks for `verbose_json` and
1552
+ leaves out segments Whisper rates `no_speech_prob` > 0.6 (OpenRouter and OpenAI-compatible providers; one
1553
+ that refuses `verbose_json` gets plain requests from then on).
1554
+ - **Endpointing**: a segment ends after 350 ms of silence. It is encoded to 16 kHz mono 16-bit WAV
705
1555
  and sent to `/api/transcribe` at once. A transcript that reads as complete is sent right away:
706
1556
  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
1557
+ Otherwise it waits up to 0.8 s. If you speak again during the wait, the next segment's transcript
708
1558
  is joined to it. Empty and filler-only transcripts are dropped. Utterances are capped at 60 s.
709
1559
  - **Commands**: an utterance that is only "stop", "cancel", "never mind", "stop that" or "hold on"
710
1560
  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
1561
+ - **Speech out**: root replies arrive one sentence at a time while they are written (`say` with `seq`);
1562
+ the phone plays them in `seq` order per text part and drops duplicates (`SayAssembler`). A `say` without
1563
+ `seq` (a subagent's result) is split into sentences. Abbreviations, decimals, file names and
712
1564
  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)
1565
+ up to two ahead of playback, and plays through one `<audio>` element while its MP3 is still downloading:
1566
+ through MediaSource (`audio/mpeg` SourceBuffer; Chrome, Android, Firefox), appending bytes as they arrive
1567
+ (`web/src/core/audio-stream.ts`). Browsers without MP3 in MediaSource (some iOS Safari versions) play each
1568
+ sentence once it has fully downloaded, as before. Prefetched sentences keep downloading while they wait. Speech while a reply plays
1569
+ (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
1570
+ being written whose next sentence has not arrived yet (their later sentences still show in the transcript); a
1571
+ new reply speaks again. Barge-in never interrupts anything: what you say steers the agent, and background
1572
+ workers keep running (only a stop phrase interrupts the agent's turn).
1573
+ - **Turn timing**: each turn's steps are `performance.mark`s (`hub-voice:speech-end`, `…:transcript`,
1574
+ `…: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
1575
+ state has the last turn as its tooltip: "last turn: 1.8 s to first audio" with the steps (transcript,
1576
+ accepted, first reply, first byte, audio). A typed turn counts from when the hub accepted it. The transcript shows your utterances with ✓ (sent)
715
1577
  and ✓✓ (heard). Agent text is merged with the bubble already streamed from deltas. Subagents
716
1578
  starting, finishing or failing appear as small notes. Typed text is sent as an utterance while the
717
1579
  mode is on.
@@ -762,12 +1624,31 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
762
1624
  assistant message); in conversation mode the spoken `say` text is merged into the bubble of the same message.
763
1625
  The home list shows the same "now" text with a spinner for working sessions.
764
1626
  - **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
1627
+ tone as soon as you stop speaking (the moment the segment goes to transcription; typed text gets it when
1628
+ the agent receives it), then a short acknowledgement ("On it.", "One moment.", "Let me check." …) if the
1629
+ session is working and the agent has not spoken within 2.5 s of the end of your speech. A hand-off phrase
1630
+ ("I'll hand that off.", "Getting a helper on it." …) plays when a subagent starts, unless the agent
1631
+ spoke in the last 3 s. A still-working phrase ("Still on it.", "Almost there." …) plays after 40 s without
1632
+ 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
1633
  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.
1634
+ speaks. Cues are event-driven: one timer for the next deadline (no polling), re-armed on speech, status and
1635
+ playback changes. Each cue has several variants (`CUE_VARIANTS` in `web/src/core/feedback-core.ts`), chosen
1636
+ from a shuffle-bag: every variant once before any repeats, never the same phrase twice in a row, and a variant
1637
+ whose audio is already loaded wins. All variants are synthesized, 2 at a time, when Voice mode starts (ending Voice mode
1638
+ 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
1639
+ session starts with them ready and a voice change fetches them again); a ready phrase starts playing at
1640
+ once. A phrase that is not ready yet when its cue fires plays a short tone instead.
1641
+ - **Cue loudness**: short TTS phrases come out several dB louder or quieter than the spoken replies, so each
1642
+ phrase is normalised while it is prefetched (never when it plays): decoded (`OfflineAudioContext`), measured
1643
+ (ITU-R BS.1770 K-weighted, gated loudness), trimmed to at most 80 ms of leading/trailing silence, gained to the
1644
+ replies' loudness with a look-ahead limiter keeping peaks at or below −1 dBFS, and stored as a 16-bit mono WAV
1645
+ (with the target it was normalised to; a target that moved by more than 1 dB re-normalises the cached audio
1646
+ without new synthesis). The target is −19.5 LUFS until replies are measured: each completed reply sentence of
1647
+ at least 1 s is decoded when the page is idle, and the median of the last 21 per TTS voice
1648
+ (`localStorage` `voice.replyLoudness`, clamped to −26…−14 LUFS) becomes the target. Audio that cannot be decoded
1649
+ plays as synthesized. Cues and replies keep their plain `<audio>` elements (no Web Audio playback graph, which
1650
+ would break background and iOS playback), both at volume 1 and the chosen speed. Pure parts:
1651
+ `web/src/core/loudness.ts`; browser decoding: `web/src/voice/cue-loudness.ts`.
771
1652
  - **Presence**: while a session view is open, the phone posts `POST /api/sessions/:id/presence
772
1653
  {visible}` on visibility changes and every 30 s while visible, and `{visible:false}` on `pagehide` and
773
1654
  when leaving the view. The hub uses this to decide when to push.
@@ -775,11 +1656,19 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
775
1656
  Screen Wake Lock keeps the screen on while listening. On: no wake lock. The mic and VAD keep running
776
1657
  when the page is hidden. A silent looping audio element plays to make suspension less likely. **This is best-effort:** whether
777
1658
  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.
1659
+ suspends it. Android Chrome usually stops the microphone when the screen turns off (the track is muted
1660
+ or ended, the AudioContext suspended, no audio frames). Voice mode watches for that: no frames for 1.5 s,
1661
+ a muted/ended track or a suspended AudioContext → the Voice bar says "Paused — mic stopped by the phone
1662
+ (screen off). Unlock to resume.", the notification becomes "Voice mode paused · unlock to talk", the lock
1663
+ screen controls show paused and a short falling tone plays. While hidden it keeps nudging the audio
1664
+ (resume every 3 s, the silent loop); it says "listening" again only once audio frames arrive. On unlock
1665
+ (visible, `pageshow`, focus, track unmute) it resumes the AudioContext, asks for the microphone again if
1666
+ the track ended (or shows "Tap to start Voice mode" when that needs a tap), retries with backoff while
1667
+ visible, and plays a rising tone. The OpenCode session keeps running on the laptop either way. When the
1668
+ phone is not listening, turn on the `say` notification to get replies as push notifications.
780
1669
  - **"Voice mode on" notification**: while Voice mode is on, the page shows a silent, persistent system
781
1670
  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
1671
+ 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
1672
  Voice mode); with no page open, the service worker turns conversation mode off at the hub itself with the
784
1673
  device cookie. It closes when Voice mode ends or the page goes away. It needs notification permission:
785
1674
  without it the Voice bar shows **Allow notifications**. The Media Session shows "Voice mode on" with the
@@ -829,6 +1718,10 @@ in the file).
829
1718
  | `speech.stt` | `{provider, model, baseURL?, language?, apiKey?}` |
830
1719
  | `speech.tts` | `{provider, model, voice, baseURL?, speed?, apiKey?}` |
831
1720
  | `auth` | `{mode, issuer?, clientId, clientSecret, allowed, admins}`; see [Multi-user](#multi-user-sign-in-with-google) |
1721
+ | `cluster` | `{database, valkey}` URLs for cluster mode (env `HUB_DATABASE_URL` / `HUB_VALKEY_URL` win); see [docs/cluster.md](docs/cluster.md) |
1722
+ | `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) |
1723
+ | `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60); see [docs/launches.md](docs/launches.md#placement-and-caps) |
1724
+ | `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
1725
 
833
1726
  Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
834
1727
  defaults. Speech: phone settings > hub.json > env vars. Values are checked like the phone's speech
@@ -975,6 +1868,7 @@ npm install # bun install works too
975
1868
  npm test # bun test (scripts/test-ci.sh runs each file alone and builds the web app first)
976
1869
  npm run typecheck # hub and web app
977
1870
  npm run build:web # web/dist, served by `bun bin/hub.ts`
1871
+ npm run build:cli # packages/cli/dist/hub-cli.js, the hub CLI bundle (prepack builds both)
978
1872
  ```
979
1873
 
980
1874
  Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
@@ -982,6 +1876,44 @@ Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (
982
1876
  `npm run test:e2e` runs a headless Chromium smoke test against the mock hub at desktop and phone sizes
983
1877
  (`CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to the temp dir).
984
1878
 
1879
+ `test/e2e-phase2.test.ts` does the same for messaging: the OpenCode spoke (with `deliver`) and a spoke without it,
1880
+ `hub msg`/`ask`/`mute` as session tokens, replies through the `hub` tool's code path, an app token with the
1881
+ `messages:*` scopes, `hub mcp` over pipes, a Decision answered on the console route, a second user who cannot see or
1882
+ answer any of it, the pair rate limit and the messaging switch. The opt-in `HUB_IT_OPENCODE=1 bun test
1883
+ test/e2e-phase2-live.it.test.ts` connects a private `opencode serve` to a real in-process hub: one session's (stub)
1884
+ model calls `hub msg` to another session, whose model is then handed the envelope.
1885
+
1886
+ `test/e2e-phase3.test.ts` does the same for agent actions: a spoke with `deliver`, `actTokens`, `hubPrompt` and
1887
+ `permissionMessage` and a raw spoke without them; a grant to `@@orchestrator` used through the `hub` tool's code
1888
+ path (act token) and an app token's grant through the CLI; shell tokens refused, never patterns (the grant's and
1889
+ hub.json's), the own-tree ban, `needs_user` escalated to a Decision answered on the console, the user answering
1890
+ first, both kill switches, the hourly cap, prompts with provenance, deny reasons, grant requests and the audit
1891
+ (admins see summaries only). `test/central-tenancy.test.ts` checks every phase-3 route and CLI verb across two
1892
+ users. The opt-in `HUB_IT_OPENCODE=1 bun test test/e2e-phase3-live.it.test.ts` connects a private `opencode serve`
1893
+ with shell permissions set to ask: session A's (stub) model runs a shell command, session B's model calls `hub
1894
+ approve` under its grant, and OpenCode runs A's command. `HUB_IT_OPENCODE=1 bun test test/spoke-acting.it.test.ts`
1895
+ checks hub prompts and deny reasons inside OpenCode.
1896
+
1897
+ `test/e2e-phase1.test.ts` (part of `npm test`) runs agent identity end to end in process: a hub, the OpenCode spoke
1898
+ and a second spoke of another harness, and the `hub` CLI as a session token, an app token, a linked device and a
1899
+ second user. The opt-in `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.test.ts` starts a private
1900
+ `opencode serve` (own port, password and XDG dirs) with a local stub model and checks a real shell: its
1901
+ `HUB_SESSION_TOKEN`, and that `hub whoami` there prints the session. `HUB_IT_OPENCODE=1 bun test
1902
+ test/spoke-deliver.it.test.ts` does the same for hub messages: the model sees the delivered envelope, a repeated
1903
+ 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
1904
+ that the hub sees the session's token while the model and the transcript never do. `HUB_IT_OPENCODE=1 bun test
1905
+ test/spoke-prompt-refs.it.test.ts` checks that a prompt naming a session reaches the model unchanged with the
1906
+ reference note attached, and that hub-sourced prompts get none.
1907
+
1908
+ `test/e2e-phase7.test.ts` runs the other harnesses end to end in process (the Claude Code daemon fed fixture hook
1909
+ inputs, the Codex spoke on a fake shared app-server daemon, a preset on a private tmux server, and claude-code and
1910
+ codex launches in node worktrees), and `test/e2e-phase7-conformance.test.ts` runs the spoke conformance suite against every shipped spoke
1911
+ (OpenCode included). The opt-in `HUB_IT_CLAUDE=1 bun test ./packages/claude-code/test/live.it.test.ts` and
1912
+ `HUB_IT_CODEX=1 bun test ./packages/codex/test/live.it.test.ts` run the real CLIs against the testkit's stub model
1913
+ with a throwaway `HOME`, `CLAUDE_CONFIG_DIR` / `CODEX_HOME` and a deny-all proxy. A new harness spoke starts from
1914
+ `bun packages/spoke-sdk/scaffold.ts <name> <dir>`, whose output passes `npx hub-conformance spoke --driver "bun
1915
+ <dir>/driver.ts"` as written ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
1916
+
985
1917
  Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
986
1918
  `{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
987
1919
  OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
@@ -1016,7 +1948,9 @@ publish before trusted publishing can take over (see the comment in `release.yml
1016
1948
 
1017
1949
  - Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no
1018
1950
  streaming STT provider yet.
1019
- - Voice detection is a simple energy detector; noisy rooms may need tuning.
1951
+ - Voice detection tells speech from noise, not whose speech it is: a TV or people talking nearby still count
1952
+ (Mic sensitivity Low helps; mute or headphones are the sure way).
1953
+ - The compiled binaries and the Docker image grow by about 14 MB (the speech model and onnxruntime's wasm).
1020
1954
  - Questions (forms) of a headless standalone server (`opencode serve --stdio` without its TUI) cannot be
1021
1955
  answered from the hub; the background service, a foreground `opencode serve` and TUI-launched
1022
1956
  standalone servers can.
@@ -1024,6 +1958,13 @@ publish before trusted publishing can take over (see the comment in `release.yml
1024
1958
  - Listening with the phone screen off depends on the browser (best-effort on Android Chrome; not
1025
1959
  possible on iOS). Replies and alerts still arrive as push notifications.
1026
1960
  - The voice agent registers as a visible primary agent (`voice`) in OpenCode's agent list.
1961
+ - A catalog change still sends every machine's full catalog to each stream (about 1 KB per few sessions); the
1962
+ replay buffer is in memory (a hub restart means a full catalog + `resync`).
1963
+ - Running shells are seeded from the spoke's own server (`shell.list` for the 20 most recent session
1964
+ directories); shells in other directories show up once they emit an event.
1965
+ - Claude Code, Codex and presets serve fewer operations than OpenCode (no timeline or summary; Claude Code takes
1966
+ hub turns only in sessions the hub launched, and its permission requests race its own dialog); see
1967
+ [docs/harnesses.md](docs/harnesses.md#known-limits).
1027
1968
 
1028
1969
  ## License
1029
1970