@huynle/hub 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/CHANGELOG.md CHANGED
@@ -1,5 +1,805 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.7.1
6
+
7
+ - **Release**: the npm package is published again. 0.7.0's publish step had no Bun for the CLI bundle, so 0.7.0 is
8
+ on GitHub and ghcr.io but not on npm; 0.7.1 is the same code.
9
+
10
+ ## 0.7.0
11
+
12
+ The agent control plane: sessions get addresses and talk to each other, ask you for Decisions, act for you only
13
+ within grants you give, and can be launched on your machines, in sandboxes, or across a cluster of hubs. Hub also
14
+ speaks to Claude Code, Codex and terminal tools, not only OpenCode. Upgrading keeps every paired device and enrolled
15
+ machine; the new data lives in `agent/` next to `state.json`, which 0.6.x ignores if you roll back.
16
+
17
+ - **Agent identity**: `@name`, `@@role` and `@me` addresses, the `hub` CLI for agents, app tokens, session tokens.
18
+ - **Agent messaging**: `msg`, `ask`, `reply`, `decline`, inboxes and Decisions (`ask @me`), the OpenCode `hub` tool,
19
+ `/who` and `/msg` in the TUI, `hub mcp`.
20
+ - **Agent actions**: grants, approve/deny/answer/prompt/stop for agents within them, escalation to Decisions, kill
21
+ switches, the agent audit (Settings → Agents).
22
+ - **Launches**: `hub launch` and `hub node`, worktree/checkout/clone workspaces, placement, signed webhooks.
23
+ - **Sandboxed launches** in OpenShell sandboxes.
24
+ - **More harnesses**: `hub claude` (Claude Code), `hub codex` (Codex), presets for other terminal tools, the spoke SDK
25
+ and conformance suite.
26
+ - **Cluster mode**: several hubs on Postgres and Valkey, `hub-server migrate` and `import`, draining, `/readyz`,
27
+ `deploy/k8s`.
28
+
29
+ The details of each part follow.
30
+
31
+
32
+ ### Agent identity
33
+
34
+ Agents, scripts and apps get their own identity on the hub: every session's shells carry a session token, `hub`
35
+ (the CLI) reads and names sessions through the new `/api/agent` API, and apps link with a code. The parts, from
36
+ the protocol up:
37
+
38
+ - **Harness-neutral spoke protocol** (still version 1, compatible both ways). The hello may carry `harness`,
39
+ `harnessVersion` and `evidence` (`native` | `hooks` | `rpc` | `screen`; anything else counts as `unknown`); the
40
+ OpenCode spoke sends `harness: "opencode"`, `evidence: "native"` and keeps `opencodeVersion` for older hubs. A
41
+ hello without `harness` is an OpenCode spoke with the implied v1 features (`IMPLIED_OPENCODE_FEATURES`); new
42
+ spokes announce all their features (`SPOKE_FEATURES`). The welcome lists the hub's features (`HUB_FEATURES`,
43
+ now `["harness"]`) and spokes remember them (`hubSupports`). `/api/machines` processes gain `harness`,
44
+ `harnessVersion` and `evidence`.
45
+ - **Device tokens, agent API framework, discovery**: every `/api` request now resolves its caller through one
46
+ credential resolver. Besides the `voice_device` cookie, the same device secret works as
47
+ `Authorization: Bearer <device token>` (for native apps and CLIs) with the same powers and without the
48
+ same-origin check; the cookie together with a hub bearer token is refused (400 `invalid`), while a bearer the hub does not know (e.g. from an auth proxy) next to the cookie is ignored. New `/api/agent/*` routes answer
49
+ errors as `{error: {code, message, hint?}}` (`unauthorized`, `token_expired`, `forbidden`, `not_found`, …)
50
+ and start with `GET /api/agent/whoami` (`{actor, user}`). `GET /.well-known/hub` (public) answers
51
+ `{publicURL, version, minClientVersion, auth: {modes, oidc?}, features}`. Existing routes keep their error
52
+ format.
53
+ - **Link native apps**: Settings → Devices → **Add app** shows a QR code and a `hubapp://link?hub=…&code=…`
54
+ link with a 5-minute countdown; the list updates once the app has linked. `POST /api/devices/link` issues
55
+ the single-use 256-bit link code (not valid at `/login` or `/api/pair`); `POST /api/devices/redeem {code,
56
+ deviceName?, publicKey?}` (public, works without `Origin` when the request carries no credential at all)
57
+ answers `{device, token}`: a device token for `Authorization: Bearer`, no cookie. An optional device public
58
+ key is stored for later strong approvals. The console's QR encoder is built in (no new dependency).
59
+ - **Session tokens**: the hub mints a token for every session (subagents included) a spoke reports for its own
60
+ user, and sends it with the `sessionTokens` op to spokes that announce the feature: on each sessions frame
61
+ (new sessions, tokens with less than a third of their 24 h left, or after a key or epoch change), again in
62
+ full after a reconnect, and from a 10-minute refresh sweep. Format `hst1.<payload>.<HMAC-SHA256>` (claims
63
+ `typ, uid, mid, sid, exp, kid, ue, me, aud`), signed with the keys in `<HUB_STATE_DIR>/token-keys.json` or
64
+ `HUB_TOKEN_KEYS`. As `Authorization: Bearer`, a session token is a session actor on `/api/agent/*` routes
65
+ that admit sessions (`whoami`), `403` on console routes, `401 token_expired` once expired and `401` once the
66
+ session is no longer reported by that machine, the machine is revoked or the user disabled (both bump the
67
+ token epoch, so re-enabling does not revive old tokens). Tokens are never logged or audited. A session's
68
+ token is visible to its shell and child processes; it is limited by its expiry, the session's liveness and
69
+ the verbs sessions may use.
70
+ - **Session tokens reach the spoke** (protocol still version 1): new server op `sessionTokens{tokens: [{sessionID,
71
+ token, exp}], hubURL?}` (at most 500 tokens per frame; `batchSessionTokens` splits a list under `MAX_FRAME`),
72
+ answered with `{applied, skipped: [{sessionID, reason: "unknown" | "expired"}]}`. It is the spoke feature
73
+ `sessionTokens`, announced by new spokes only (not implied for older OpenCode spokes), so hubs send it only to
74
+ spokes that list it. The OpenCode spoke keeps each reported session's latest token in memory (never logged,
75
+ dropped when expired or when the connection changes) and adopts no session for it.
76
+ - **Session identity in shells**: every shell an agent runs through OpenCode's shell tool gets its session's
77
+ `HUB_SESSION_TOKEN` and `HUB_URL` (the spoke's server), set by plugin hooks (tool `execute.before` paired with
78
+ shell `create.before` by command; the same command pending from two sessions gets no token). Inherited
79
+ `HUB_URL`/`HUB_SESSION_TOKEN` are removed from the OpenCode process at plugin start and from every shell, and the
80
+ hub bin dir (`$HUB_STATE_DIR/bin`, default `~/.local/state/hub/bin`) goes first on PATH. A deleted session's token
81
+ is dropped. User `!` commands get no token. Opt-in integration test: `HUB_IT_OPENCODE=1 bun test
82
+ test/spoke-env-inject.it.test.ts`.
83
+ - **App tokens**: a signed-in user (device cookie or device token) creates app/agent tokens with
84
+ `POST /api/agent/app-tokens {label?, scopes?}` → `201 {id, token, label?, scopes, created}` (the token,
85
+ `hat_<id>_<secret>`, is shown only here), lists them with `GET /api/agent/app-tokens` → `{tokens}` (no secrets)
86
+ and revokes one with `DELETE /api/agent/app-tokens/:id` → `204`. Labels ≤ 100 characters; scopes from
87
+ `sessions:read` (the default); at most 50 per user (`429 rate_limited`). Only sha256 hashes are stored. An app
88
+ token is an app actor on `/api/agent/*` routes its scopes allow (`whoami` always); it is refused on console
89
+ routes and on token management (`403 forbidden`), and once revoked or its user disabled (`401`). Creation and
90
+ revocation are audited (`app-token.create`, `app-token.revoke`).
91
+ - **Session names and roles** (`@name`, `@@role`): the hub keeps a per-user record of every session its spokes
92
+ report (machine, parent, project, lastSeen; at most 2000). Root sessions get a name once, from their first
93
+ real title (OpenCode's "New session" placeholders are skipped): its first few words (at most 4 and 24
94
+ characters, cut at a word boundary, ID-like tokens such as `ses_…` dropped), with `-2`, `-3` on a clash.
95
+ `POST /api/agent/name {name, session?}` sets one (409 `name_taken` when taken); the old name stays an alias for
96
+ 24 h; a name is released 7 days after its session is archived (the console's Archive) or deleted.
97
+ `POST /api/agent/roles/claim {role, exclusive?, project?, session?}` / `roles/release` manage roles
98
+ (`role.project` scopes; every new holder gets a new `generation`; an exclusive claim fails while another live
99
+ holder exists; a holder offline for 30 min loses the role, except in the 2 min after the hub starts;
100
+ some role names, such as `orchestrator…`, are reserved to the user).
101
+ `GET /api/agent/who?project=&role=` lists named, role-holding and online sessions. Session tokens may read,
102
+ and name or claim for their own session only.
103
+ Addresses (`ses_…` or a unique ID prefix, `@name[.project]`, `@@role[.project]`) resolve within the caller's
104
+ sessions only: another user's are `not_found`, and `ambiguous` candidates never list them.
105
+ - **Agent reading** (`/api/agent/*`, for users, session tokens and app tokens with `sessions:read`):
106
+ `GET /api/agent/status` answers the caller's snapshot `{at, you, machines: [{id, name, online, harnesses,
107
+ projects: [{root, name, sessions}]}], attention}`; each session is `{id, name?, roles, title, state, harness,
108
+ parentID?, busySince?, now?, blockers: [{kind, id, summary}], children, labels}` and `attention` lists every
109
+ blocker and error, oldest first. `GET /api/agent/sessions?project=&state=&machine=&label=k=v` is the same
110
+ sessions as a flat list (with `machineID`, `project`). Both read only the hub's catalog and never call a spoke.
111
+ `GET /api/agent/sessions/:addr` adds full blockers (form fields), `directory`, `online` and the spoke's
112
+ `summary` (`unsupported: [{feature, harness}]` instead when the spoke lacks it; `state: "offline"` for a known
113
+ session nobody reports). `GET /api/agent/sessions/:addr/log?n=20` (1–200) answers `{sessionID, rows, older?}`,
114
+ newest first, from the spoke's `timeline` (422 `unsupported {feature, harness}` without it, 503 `offline`).
115
+ `GET /api/agent/watch?blocked=1&session=<addr>` is an SSE stream of compact JSON events for the caller's own
116
+ sessions (`ready`, `state`, `blocker.opened`, `blocker.closed`, `session.added`, `session.removed`; no text),
117
+ with a `ping` event every 20 s. Another user's sessions, names and roles answer `404 not_found` on every route.
118
+ - **hub CLI** (`hub`, alias `hubctl`; private workspace `packages/cli`): `status`, `sessions [--project --state
119
+ --machine --label]`, `show <addr>`, `log <addr> [-n]`, `watch [--blocked] [--session]` (JSON lines), `whoami`,
120
+ `who [--project] [--role]`, `name <@name>`, `role claim|release <role> [--exclusive] [--session]`, `login`/`logout`.
121
+ Short text by default, `--json` for the raw answer; exit codes 0 ok, 1 other, 2 usage, 3 not found/ambiguous/
122
+ taken, 4 needs user, 5 forbidden/paused, 6 rate limited, 7 unsupported/offline, 8 gone, 9 unauthorized/expired.
123
+ Credentials: `HUB_URL` + `HUB_SESSION_TOKEN` first, else `~/.config/hub/cli.json` (0600) written by `hub login`
124
+ with an app token. Shipped as one JS bundle (`packages/cli/dist/hub-cli.js`, `npm run build:cli`, built by
125
+ prepack); the plugin writes `hub` and `hubctl` launchers into the hub bin dir at start that run it with the
126
+ OpenCode binary as bun (`BUN_BE_BUN=1`; `node` without Bun; the TypeScript source in a checkout without a build).
127
+ - **Tests**: `test/e2e-phase1.test.ts` (in `npm test`) runs all of it end to end in process: a hub, the OpenCode
128
+ spoke and a second spoke of another harness (`fake`, evidence `hooks`, no features: no tokens, `hub log`
129
+ unsupported), and the CLI as a session token, an app token (`hub login`), a linked device and a second user (who
130
+ sees nothing: exit 3), until revoking the machine ends the session token (exit 9). The opt-in
131
+ `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.test.ts` also runs `hub whoami` in a real OpenCode shell
132
+ against an in-process hub and checks it prints the session.
133
+
134
+ ### Agent messaging
135
+
136
+ Sessions, scripts, apps and you message each other through the hub: `msg`, `ask`, `reply`, `decline`, `inbox` and
137
+ `mute` in the `hub` CLI, the OpenCode `hub` tool, the TUI's `/msg` and `/who`, and `hub mcp`; asks to `@me` become
138
+ Decisions answered in the console. Messages reach OpenCode sessions as hub messages in their conversation, in an
139
+ envelope that marks them as not coming from the user. See the README's *Agent messaging* section and
140
+ [docs/multi-user.md](docs/multi-user.md#messaging-tenancy).
141
+
142
+ - **Delivery engine**: messages go only through spokes that announce `deliver` (an older spoke or another harness
143
+ without it: `unsupported {feature: "deliver"}`, nothing stored; no fallback through steer). A role address picks
144
+ one holder (the idle one, else the most recently active); `all` sends to each. Offline recipients get the message
145
+ when they come back (pending for 24 h); an open ask to an idle recipient is re-woken after 5, 15 and 45 minutes,
146
+ then the user is notified. Messages, asks, pending deliveries, Decisions and mutes live under
147
+ `<HUB_STATE_DIR>/agent/` (one file per user and collection, 0600), not in `state.json`.
148
+ - **Envelope**: `[hub] <kind> from <sender> <to> · <time> · <id>`, the body between two boundary lines carrying the
149
+ message id, and a reply hint. Body lines that look like a header, boundary or hint are escaped; control, ANSI,
150
+ bidi and zero-width characters are stripped from the text and from names.
151
+ - **Identity primer**: once per session (when it is first named explicitly or gets its first message), a short
152
+ note without a turn: who the session is, how to message others, and that `[hub]` messages are not the user's.
153
+ - **Loop protection**: per 10 minutes, 20 messages per sender→recipient pair and 300 per user (`429
154
+ rate_limited` with `retryAfter`), 30 turns started per recipient (then delivered without waking); 8 hops of
155
+ agent-to-agent replies without a human turn (then delivered without waking, the user notified once). Mutes
156
+ refuse a sender (`403 forbidden`); the per-user messaging switch refuses all sends (`423 paused`).
157
+ - **The name `me` is reserved**: `@me` always means the user, so no session can be named `me` (`400 invalid`) and
158
+ a title like "Me" derives `me-2`.
159
+ - **CLI messaging verbs**: `hub msg`, `hub ask [--wait] [--expires]`, `hub reply`, `hub decline --why`, `hub
160
+ inbox`, `hub mute` / `unmute`, with `--all`, `--no-wake`, `--urgent` and `-` for text on stdin. Exit codes: 5
161
+ `forbidden`/`paused`, 6 `rate_limited`, 7 `unsupported`, 8 `gone` (or an expired ask); a waiting ask exits 1
162
+ when declined or not answered in time.
163
+ - **`hub mcp`**: a Model Context Protocol server on stdio (newline-delimited JSON-RPC; `initialize`, `ping`,
164
+ `tools/list`, `tools/call`) whose tools are the CLI's verbs, acting as the CLI's credential (in practice the
165
+ app token from `hub login`). Only protocol frames go to stdout.
166
+ - **Console message rows**: hub messages show in a session's conversation, "from @y" for received ones (from the
167
+ spoke's timeline) and "to @@r (→ @x)" for sent ones (from the hub, `GET /api/sessions/:session/messages` and SSE
168
+ `message` events), merged by time; the primer folds into one line.
169
+ - **OpenCode `hub` tool**: the plugin adds one tool `hub {verb, …}` (verbs `msg`, `ask`, `reply`, `decline`,
170
+ `inbox`, `who`, `status`, `whoami`; JSON Schema input, not in code mode) that calls `/api/agent` through the SDK
171
+ as the calling session, with that session's own token (never in the tool's output or metadata; redacted should
172
+ a hub echo it). Answers and inbox items show in the hub's sanitised envelope; the description and results say
173
+ messages from other sessions are not from the user. `ask` waits at most 10 minutes (`wait` seconds) and stops
174
+ when the tool call is aborted, naming the still-open ask. Readable errors when the session has no token yet
175
+ or the hub lacks `messaging` (messaging verbs only). Opt-in check: `HUB_IT_OPENCODE=1 bun test
176
+ test/spoke-hub-tool.it.test.ts`.
177
+ - **TUI `/who` and `/msg`**: `/who [project | @@role]` lists the hub's named and role-holding sessions (name,
178
+ project, state, online, roles) in a dialog; `/msg @name text` (also `@@role`, `@name.project`, `ses_…`, `@me`)
179
+ sends the text word for word as the session on screen, with that session's own hub token, and toasts where it
180
+ went (`Sent to @bob (queued)`). `/msg` alone (or from the palette) asks for `@name text`. On the home screen
181
+ both say to open a session first. New RPC methods `hubWho {sessionID, project?, role?}` and `hubMsg
182
+ {sessionID, to, text}` (error `unsupported` when the hub lacks `messaging`); Hub commands may now take slash
183
+ arguments (`HubCommand.arguments`, `run(input?)`).
184
+ - **Prompt references**: `@name`, `@name.project`, `@@role` and `@@role.project` typed into an OpenCode prompt
185
+ still go to that session unchanged; the plugin's prompt hook adds one attached text file
186
+ (`hub-references.txt`, a `data:` URI the model sees inside the same message) listing each address that names
187
+ one of your sessions (name, session ID, harness, machine, project, state, your open asks to it) and saying to
188
+ contact it with the `hub` tool. OpenCode's own `@` meanings win (an attached file or agent mention for the same
189
+ `@x` is left alone), `@me` and unknown or other users' names add nothing, and hub-delivered prompts are skipped.
190
+ The lookup (`who`, `status`, `inbox` as the session) runs only when an address appears, gives up after 1.5 s,
191
+ fails open and is cached for 10 s per session. Opt-in check: `HUB_IT_OPENCODE=1 bun test
192
+ test/spoke-prompt-refs.it.test.ts`.
193
+ - **Decisions and `@me`**: `@me` addresses the user. `msg @me` is stored in the user's inbox; `ask @me` opens a
194
+ Decision (`dec_…`: question, `options [{id, label, safe?}]`, `recommendation {option, why?}`, `urgency`,
195
+ `tier` confirm|tap, `deadline` seconds, `onTimeout`, `about` addresses), answered only by the user on the console:
196
+ `GET /api/decisions?state=`, `GET /api/decisions/:id`, `POST /api/decisions/:id/answer {option, text?}`
197
+ (`tap`: the console cookie only). The first answer wins (later: `409 gone`) and goes back to the asker as the
198
+ user's reply; at the deadline only a `safe` `onTimeout` answers it (`answeredBy: "timeout"`), otherwise it stays
199
+ open and is pushed once more. Agents read their own with `GET /api/agent/decisions[/:id]`; a Decision's ask
200
+ cannot be replied to through `/api/agent/reply`. SDK `ask` takes the Decision fields, plus `decisions`,
201
+ `decision`, `answerDecision`; CLI `hub ask @me --option id=label[:safe] --recommend id --why … --urgency …
202
+ --deadline 2h --on-timeout id --tier … --about @x` and `hub decisions list [--state S]`.
203
+ - **Decision cards in the console**: open Decisions show on Home and in the session view of the asking session
204
+ and the sessions they are about: question, options (the recommended one marked, with why; safe ones marked),
205
+ urgency, a live deadline countdown with the timeout's option, and "Answer here (tap)" for `tap` Decisions.
206
+ Answer with an option (and an optional note) or text; an answer that lost the race says "Already answered or
207
+ expired". SSE `{type: "decision", decision}` goes to the user's streams whenever a Decision opens, is
208
+ answered (also by timeout), escalates or expires; a `decision` push carries `decisionID` and its tap opens
209
+ `/s/<session>?decision=<id>` (or `/?decision=…`), which marks the card.
210
+ - **Push kinds `decision` and `message`** (both on by default, high urgency, sent even while a device shows the
211
+ session, generic bodies): a Decision opened or escalated; a `msg` to `@me`, an unanswered ask after every
212
+ re-wake, or a message loop (these two were `question` before).
213
+ - **Message routes on `/api/agent`**: `POST msg {to, text, wake?, urgent?, all?}`, `POST ask {…, expiresIn?,
214
+ wait?}` (seconds; `wait` ≤ 60 long-polls for the answer), `POST reply {replyTo, text, wake?}`, `POST decline
215
+ {replyTo, why}`, `GET inbox` (`{asks, waiting, messages, muted}`), `GET messages/:id?wait=`, `POST mute` /
216
+ `unmute {session}`, and the per-user switch `GET`/`PUT messaging {enabled}` (users only; off: sends answer
217
+ `423 paused`). The sender is always the credential (body fields are ignored); a session cannot message
218
+ itself (`400 invalid`, also left out of a role it holds); only a message's recipient may answer it, anyone
219
+ else and foreign ids get `404 not_found`. The user and apps have a stored-only inbox. New app token scopes
220
+ `messages:send` and `messages:read`. `GET /api/agent/watch` adds `message` events (no text; users see all,
221
+ sessions and apps only their own). Console: `GET /api/sessions/:session/messages?limit=` lists a session's
222
+ sent and received messages, newest first.
223
+ - **SDK messaging verbs**: `msg`, `ask` (with `wait` looping over polls of at most 60 s), `message`, `reply`,
224
+ `decline`, `inbox`, `mute`, `unmute`.
225
+ - **OpenCode spoke delivers hub messages** (`deliver` announced when OpenCode has `session.synthetic`). The
226
+ envelope goes in as a synthetic message with metadata `{source: "hub", messageID, kind, from, to, replyTo?}`
227
+ (the model sees only the text) and OpenCode's client id (the hub's `msg_…` id, else a stable `msg_hub…` id
228
+ derived from it), so a retry is applied once. An idle session gets a turn (`turn`); a busy one gets a plain
229
+ message after the running turn (`queued`) and an ask into it (`steer`) unless `meta.delivery` says otherwise;
230
+ with a form open OpenCode holds it (`retained`). `wake: false` and subagent sessions are admitted without
231
+ starting a turn (`queued`, steer without resume: it arrives with the next turn, or joins one already running).
232
+ A repeated `messageID` returns the first result without another admission (only a retry that now asks to wake
233
+ a message admitted without waking is sent again). No voice model, agent or conversation changes. For hubs
234
+ with `messaging`, `session.inbox.delivered` carries `origin: "user"` (typed or phone prompts) or `"hub"` (a
235
+ delivered message) when the spoke saw the item enqueued. Opt-in check against a real `opencode serve`:
236
+ `HUB_IT_OPENCODE=1 bun test test/spoke-deliver.it.test.ts`.
237
+ - **Protocol: `deliver` op and messaging features** (still version 1). The hub admits a message into a session
238
+ with `deliver {sessionID, messageID, text, wake, meta: {kind, from, to, replyTo?, delivery?}}`, answered with
239
+ `{delivered: "steer" | "turn" | "queued" | "retained", inboxID?}`. `kind` is `msg`, `ask`, `reply`,
240
+ `decline`, `primer` or `decision` (`reply` and `decline` need `replyTo`); `delivery` is `queue` or `steer`;
241
+ `text` is at most 16000 characters, ids and addresses at most 300, and addresses may not contain control,
242
+ bidi or zero-width characters (`deliverProblem`, `isDeliverResult`). Spokes announce `deliver` in
243
+ `SPOKE_FEATURES` (old OpenCode spokes do not imply it, so the hub must check before sending); the welcome's
244
+ `HUB_FEATURES` gains `messaging`. `session.inbox.delivered` may carry `origin: "user" | "hub"` (same event
245
+ type, so older hubs keep accepting it), and timeline rows gain the `message` kind (`TimelineMessageItem`).
246
+ - **Tests**: `test/e2e-phase2.test.ts` runs messaging end to end in process (a spoke with and one without
247
+ `deliver`, the CLI as sessions, an app and two users, the `hub` tool's code path, `hub mcp` over pipes, a Decision
248
+ answered on the console route, mutes, the pair limit and the messaging switch). Opt-in against a real `opencode
249
+ serve`: `HUB_IT_OPENCODE=1 bun test test/e2e-phase2-live.it.test.ts` (one session's model messages another).
250
+ - **Internal**: the pure envelope renderer, sanitiser and address parser moved into `packages/sdk` (shared by the
251
+ hub, the plugin and `hub mcp`); the Docker image now copies `packages/sdk/src`.
252
+
253
+ ### Agent actions
254
+
255
+ Agents can now act on your sessions — approve or deny permission requests, answer forms, prompt and stop sessions
256
+ — within grants you create, with everything outside a grant coming back to you as a Decision, two kill switches and
257
+ a per-user audit. See the README's *Agent actions and grants* and
258
+ [docs/multi-user.md](docs/multi-user.md#grants-tenancy).
259
+
260
+ - **Grant policy**: patterns `action[:glob]` with OpenCode's wildcard semantics (`bash` is `shell`); a request is
261
+ allowed only when every resource matches an allow pattern, never beats allow (also on the rules an `always`
262
+ reply would save, which must lie inside the allow patterns), and in shell allow patterns `$(`, backticks and
263
+ `<`/`>` must be written literally. Never patterns also match the command without leading assignments and
264
+ wrappers (`sudo`, `env`, `command`, `exec`, `nohup`, `time`). Escalation tiers are the hub's: `tap` for shell,
265
+ `external_directory`, MCP and unknown tools and anything near a never rule, else `confirm`. Project scopes are
266
+ canonical absolute paths matched on path boundaries.
267
+ - **Grants** (`/api/agent/grants`: `POST`, `GET`, `GET`/`PUT`/`DELETE …/:id`; users only): holder `ses_…` or
268
+ `@name` (pinned to the session at creation), a reserved `@@orchestrator…` role (follows its holder) or
269
+ `app:<token id>`; `scope.projects` (1–20), `allow` verbs (`approve`, `deny`, `answer`, `prompt`, `stop`, `roles`),
270
+ `permissions {allow, never}`, `expires` (1 minute–30 days, default 8 h), `maxActionsPerHour` (1–1000, default
271
+ 60; past it `429 rate_limited`), `allowAlways`. At most 50 active per user; revoked and expired ones stay listed
272
+ (`state`). Owner, holder and token are re-checked on every use.
273
+ - **hub.json `agents.never`**: patterns no agent may approve for any user, checked before every grant.
274
+ - **Act verbs** (`POST /api/agent/sessions/:addr/approve {requestID, always?, why?}`, `…/deny {requestID, why?}`,
275
+ `…/answer {formID, answer | cancel}`, `…/prompt {text, delivery?}`, `…/stop`): users on their own sessions;
276
+ sessions only with their act token; app tokens with the new scope `sessions:act`, under a grant. Check order:
277
+ tenancy → kill switches → grant → own session tree (a session never answers in its own tree) → hub never → grant
278
+ never → allow (else `403 needs_user` with the evaluation `{verdict, reason, tier, grants}`) → hourly cap → kill
279
+ switches again → spoke. Errors: `needs_user`/`forbidden` 403, `not_found` 404, `gone` 410 (answered elsewhere
280
+ first), `unsupported` 422, `paused` 423, `rate_limited` 429, `offline` 503.
281
+ - **Act tokens**: the hub mints a second token per session (`typ: "act"`, same format) and pushes it with the new
282
+ `actTokens` op only to spokes announcing that feature. The OpenCode spoke keeps them apart from shell tokens
283
+ (`actIdentity`), never in any environment or tool output. Shell tokens (`HUB_SESSION_TOKEN`) can never act
284
+ (`403 forbidden`).
285
+ - **Escalations**: an approval outside the grant opens one Decision per agent and request (Approve once / Approve
286
+ always / Deny, the agent's `why` as its recommendation, the hub's tier); asking again updates its reason. The
287
+ user's answer replies to the permission as the user (no grant or kill switch applies), audited as the user's. A
288
+ request answered elsewhere or ended closes the Decision (new Decision state `closed`; the console drops it from
289
+ its open list) and the agent's retry is `not_found`. The request's blocker shows the recommendation, and so does
290
+ the escalation push (fetched by the service worker); permissions escalated to `tap` Decisions get no
291
+ Allow/Reject in the notification. Limits: 20 new escalations per agent an hour, 20 open per user; pushes within
292
+ a minute coalesce. Decisions gain actions run on the answer (permission and grant kinds), quiet opens and
293
+ revisions.
294
+ - **Grant requests**: `POST /api/agent/grants/requests {grant, why}` from a session (any token) or an app token with
295
+ `messages:send` opens a tap Decision summarising the grant; approving creates it as the user's (holder pinned to
296
+ the requester, or a reserved role it holds) and the requester gets the grant id in a reply. One open request per
297
+ requester, 5 an hour.
298
+ - **Hub prompts with provenance**: an agent's prompt is a `deliver` of kind `prompt` naming the sender and the grant
299
+ (spoke feature `hubPrompt`): the spoke wraps it in `[hub] prompt from <sender> · grant <id> · <msg id>` with
300
+ boundary lines and admits it as the hub's (inbox origin `hub`), never as a human turn, so it does not reset the
301
+ hop chain. Agents prompt root sessions only, never themselves, text only, always waking. The user's prompt stays a
302
+ human turn.
303
+ - **Deny reasons**: a reject carries an optional `message` (spoke feature `permissionMessage`); the OpenCode spoke
304
+ passes it to OpenCode as the rejection's feedback, prefixed with who denied it and under which grant. Older
305
+ spokes take the reject without it (`reasonSent: false`).
306
+ - **Kill switches**: the user's `GET`/`PUT /api/agent/acting {enabled}` and the admin's hub-wide
307
+ `GET`/`PUT /api/admin/agents/acting {enabled}`; either pauses every agent action (`423 paused`) while reading,
308
+ messaging and the user's own actions keep working.
309
+ - **Agent audit**: every agent action and attempt (decision `allow`, `needs_user`, `forbidden`, `paused`,
310
+ `rate_limited`; outcome `ok`, `gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`), switch changes,
311
+ escalation answers and grant requests, in a per-user ring of 2000: `GET /api/agent/audit?limit=&before=` (users
312
+ only) and SSE `{type: "agent", entry}` to the owner. Admins get summaries only (`agent.<verb>`, the actor kind).
313
+ - **Clients**: SDK act verbs (`approve`, `deny`, `answer`, `cancelForm`, `prompt`, `stop`), grants (`grants`,
314
+ `grant`, `createGrant`, `updateGrant`, `revokeGrant`, `requestGrant`) and `needsUserOf`; CLI `hub approve | deny |
315
+ answer | prompt | stop` (needs user: exit 4 with the evaluation and the Decision), `hub grant list | show | create |
316
+ revoke | request`, `hub decisions list --state closed`; `hub mcp` tools `approve`, `deny`, `answer`, `prompt`,
317
+ `stop`; the OpenCode `hub` tool's act verbs (with the session's act token; `needs_user` reads as "the user
318
+ decides, do not retry").
319
+ - **Console**: Settings → Agents lists grants (state, holder, projects, verbs, never patterns, expiry countdown)
320
+ with create, edit and revoke, the acting switch (and the hub-wide one for admins) and an Activity tab (the agent
321
+ audit, paged). Session views show agents' recommendations on blockers, who answered a Decision, and escalation
322
+ details on Decision cards.
323
+ - **Tests**: `test/e2e-phase3.test.ts` runs it end to end in process (a real spoke with `deliver`, `actTokens`,
324
+ `hubPrompt` and `permissionMessage` and a raw spoke without; the holder acting through the `hub` tool's code path
325
+ and an app token through the CLI; shell tokens, never rules, the own-tree ban, escalation and the user answering
326
+ first, kill switches, the hourly cap, prompts, deny reasons, grant requests and the audit), and
327
+ `test/central-tenancy.test.ts` covers every phase-3 route and CLI verb across users. Opt-in against a real
328
+ `opencode serve`: `HUB_IT_OPENCODE=1 bun test test/e2e-phase3-live.it.test.ts` (one session's model approves
329
+ another's shell permission through the `hub` tool) and `test/spoke-acting.it.test.ts` (hub prompts, deny reasons).
330
+
331
+ ### Launches
332
+
333
+ You, a script or an app can now start a session on one of your machines and have it end by itself: a `hub node`
334
+ daemon prepares a git worktree (or a checkout or a clone), the machine's OpenCode service creates the session in it,
335
+ and the hub names it, gives it roles, labels and a first prompt, watches it to its end and tells apps through signed
336
+ webhooks. See [docs/launches.md](docs/launches.md) and [docs/multi-user.md](docs/multi-user.md#launch-tenancy).
337
+
338
+ - **Protocol** (still version 1): a node hello on `/spoke/ws` (role `node`: runtimes, labels, capacity and use,
339
+ workspace roots, draining, the launches it holds), node ops `prepare`, `collect`, `cleanup` and `cancel` with
340
+ `progress` and `capacity` frames, the launch spec and launch view types, and the spoke feature `launch` (a create
341
+ op carrying `launchID`, `agent` and `model`). Hub features `nodes` and `launches` are announced only by hubs that
342
+ serve them; the webhook signature format (`Hub-Signature: t=…,v1=…`, `Hub-Event`, `Hub-Delivery`) lives in the
343
+ protocol package.
344
+ - **Launch spec and store**: `POST /api/agent/launches` bodies are validated strictly (unknown fields refused; git
345
+ branches per `git check-ref-format` with no option-like parts; https/ssh remotes only, without credentials;
346
+ absolute repo paths; bounded name, roles, prompt, labels and timeout) with defaults filled in (harness
347
+ `opencode`, mode `worktree`, runtime `host`, end `idle`, cleanup `keep`). Launches are stored per user with a
348
+ state machine `queued → placing → provisioning → running → ended{idle, needs_user, failed, timeout, stopped,
349
+ lost}`, a bounded step history and idempotency keys (same key and body: the same launch; another body:
350
+ `400 invalid`); ended launches are pruned after 7 days (at most 500 kept).
351
+ - **Verb `launch` and scope `sessions:launch`**: grants may allow `launch` for their projects; app tokens may carry
352
+ `sessions:launch`.
353
+ - **Nodes and placement**: the hub keeps a registry of connected `hub node` daemons per machine owner and places
354
+ each launch on one of the user's own nodes (runtime, harness, workspace root, a service spoke announcing
355
+ `launch`, `placement.machine` and labels as `require` or `prefer`, free slots), oldest first, under a per-user
356
+ lease so several hub processes never place the same launch. Per-user caps in `hub.json agents.launches`
357
+ (`maxQueued` 50, `maxActive` 4, `perHour` 60 by default).
358
+ - **Spoke launch creates**: the OpenCode service spoke announces `launch` and creates a launch's session (with its
359
+ agent and model) only in a directory under the node's workspace roots, which it reads from `node.json` beside its
360
+ config.
361
+ - **`hub node`** (packages `@huynle/hub-node` and `@huynle/hub-runtime-sdk`): the per-machine daemon, configured by
362
+ `node.json` (`workspaceRoots`, `labels`, `slots`, `memoryMB`) and connecting with the machine token it reads,
363
+ read-only, from the spoke's `config.json`. Its host runtime makes worktrees at `<repo>/.worktrees/<branch>`
364
+ (reused when the branch already has one), checkouts and clones, all resolved through symlinks under the roots;
365
+ git runs without a shell, only over https and ssh, without prompts or inherited `GIT_*` settings, with time
366
+ limits and cancellation. Cleanup never forces: it keeps reused workspaces, checkouts, roots, workspaces another
367
+ launch made (a marker in the git directory), uncommitted changes and unpushed clones. It reconnects with backoff,
368
+ waits 30 minutes between tries on a hub that does not serve nodes, and stops when its machine is revoked;
369
+ launchd and systemd unit templates in `scripts/`. Runs on Bun and Node.
370
+ - **Launch engine**: each placed launch is prepared on the node, created through the machine's service spoke, given
371
+ its roles, its name and the hub's primer, then prompted once, and runs. It ends `idle` only after its prompt was
372
+ seen working and the session then stayed idle (no blockers, asks or open Decisions) for 30 seconds; `asked` also
373
+ ends `needs_user`; `never` only by a stop or `end.timeout`; a session unreported for 10 minutes, or a node gone
374
+ while provisioning, ends `lost`. At the end the hub stops the session when needed, collects artifacts (branch,
375
+ head, commits, dirty) and asks the node to clean up (later, when the node is away). A restarted hub re-places
376
+ queued launches, continues provisioning ones from their last step and watches running ones again.
377
+ - **Launch API**: `POST`/`GET /api/agent/launches` (`?state=`, `?label=k=v`), `GET`/`DELETE
378
+ /api/agent/launches/:id` (`410 gone` once ended), for users, app tokens with `sessions:launch` and sessions through
379
+ their act token, each under a grant with verb `launch` covering the repo (users need none); the kill switches
380
+ pause apps and sessions (`423`); apps and sessions see and stop only their own launches; every launch and stop is
381
+ audited. Launch labels show on read routes and filter lists; every change is an SSE `launch` event and a `launch`
382
+ watch event; the welcome announces `launches`.
383
+ - **Webhooks**: `POST`/`GET /api/agent/webhooks`, `GET`/`DELETE /api/agent/webhooks/:id` for users (all of theirs)
384
+ and app tokens with `sessions:launch` (only their own, and only events of launches they started). Each launch
385
+ change (`launch.queued`, `launch.provisioning`, `launch.running`, `launch.ended`) is POSTed as a CloudEvents-style
386
+ JSON body, signed with the registration's `whsec_…` secret (HMAC-SHA256 of `<t>.<body>`), from a durable outbox:
387
+ at least once, deduplicated, retried with backoff for about 8.5 hours. Targets must be `https://` on public hosts,
388
+ checked again (re-resolved) before every send, with no redirects, a 10 s timeout and a 64 KiB response cap;
389
+ `hub.json agents.webhooks.allowPrivate` admits `http://` and private hosts for self-hosted receivers. Push
390
+ targets share the same public-URL checks.
391
+ - **Clients**: the SDK gains `hub.launches.create/list/get/stop` and `verifyWebhook` / `signWebhook` (constant-time,
392
+ 5-minute tolerance, several secrets while rotating); the CLI `hub launch` (every spec field as a flag;
393
+ `--prompt -` reads stdin), `hub launches list/show/stop` and `hub node run/install/uninstall/status` (install
394
+ writes the unit and prints the commands that load it; it never runs them); `hub mcp` adds the tools `launch`,
395
+ `launches`, `launch_show` and `launch_stop`.
396
+ - **Tests**: `test/e2e-phase4.test.ts` runs a hub, a fake OpenCode service spoke and a real `hub node` (host runtime)
397
+ on temporary git repositories: `hub launch` through every state into a real worktree with name, role, labels,
398
+ primer and prompt, the idle end with artifacts and the worktree removed, signed webhooks checked with
399
+ `verifyWebhook`, idempotent keys, `maxActive`, app tokens with and without a launch grant, the kill switch, a
400
+ session's shell token refused, tenancy and a node lost while preparing. Opt-in against a real
401
+ `opencode serve --service`: `HUB_IT_OPENCODE=1 bun test test/e2e-phase4-live.it.test.ts` (the launched session
402
+ runs in the node's worktree and the model receives the prompt).
403
+
404
+ ### Sandboxed launches
405
+
406
+ - Launches can run in OpenShell sandboxes (`runtime: {kind: "openshell", profile}`, clone workspaces only): the node
407
+ clones on the host, creates a labelled sandbox in the OpenShell workspace `hub` with a deny-by-default policy,
408
+ moves code in and out as git bundles, starts the harness from the sandbox image and collects the work into the
409
+ host clone ([docs/launches.md](docs/launches.md#sandboxed-launches-openshell-runtime)).
410
+ - Launch-bound tokens (`hlt1`) let a sandboxed plugin connect for one launch only; the hub keeps such connections
411
+ out of service selection, closes them when the launch ends and gives their sessions no act tokens.
412
+ - The plugin's launch-bound mode reads `$HUB_LAUNCH_CONFIG` (or `/sandbox/.hub/config.json`) and never enrolls,
413
+ starts a local hub or writes config.
414
+ - node.json `openshell` section (`enabled`, `gateway`, `image`, `profiles`); the node announces the `sandbox`
415
+ feature, reports its sandboxes after a restart, and falls back to `host` when the CLI cannot be used.
416
+ - Profiles compile to OpenShell policies (hub, optional git remote, extra endpoints, only for the harness binary);
417
+ profiles asking for mounts or CPU/memory limits are refused.
418
+ - The sandbox image (`packages/runtime-openshell/image/`) with start and entry scripts and an upload fallback.
419
+ - `hub node openshell check [--json]` reports whether a node can run sandboxed launches.
420
+
421
+ ### More harnesses
422
+
423
+ Claude Code, Codex and terminal agents described by a preset connect to the hub next to OpenCode, each through a
424
+ spoke that announces exactly what it can do ([docs/harnesses.md](docs/harnesses.md)), and new harness spokes have
425
+ an SDK, a test kit, a conformance suite and a scaffold ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
426
+
427
+ - **Protocol** (still version 1, compatible both ways). Core ops (`turn`, `steer`, `interrupt`, `stop`,
428
+ `permission`, `form`) are announced features for non-OpenCode hellos; OpenCode hellos keep implying them.
429
+ `opencodeVersion` is optional for other harnesses. The harness event subset (`HARNESS_EVENTS`,
430
+ `harnessEventProblem`) fixes which forwarded events another harness's spoke may send and their shapes, including
431
+ `session.execution.interrupted {inferred: true}` for interruptions the harness never reported and
432
+ `session.inbox.delivered {origin}`. New feature `turnEvents` (every turn end is the harness's own). The harness
433
+ preset schema (`HarnessPreset`, `presetProblem`): data only, an argv without shell syntax, delivery `paste`,
434
+ evidence `screen`, built-in names reserved.
435
+ - **Hub.** One service spoke per machine and harness (`serviceFor(machine, harness)`): a Claude Code or Codex
436
+ service never hides the OpenCode one. Launches and placement use the service spoke of `spec.harness`. Ops a spoke
437
+ did not announce are refused before they reach it: agent API `422 unsupported {feature, harness}`, console `409`
438
+ code `unsupported`, CLI exit 7, a launch fails with the reason; an escalated permission Decision for such a spoke
439
+ closes as `unsupported`. Events from non-OpenCode spokes are validated against the subset (invalid ones dropped
440
+ with a rate-limited warning). Idle by real evidence trusts `native` and `hooks`, and `rpc` only with
441
+ `turnEvents`; after an inferred interruption it waits 5 s.
442
+ - **Console.** Controls follow each process's announced features (OpenCode unchanged); other harnesses' sessions
443
+ show an evidence badge (Hooks, RPC, Screen, Unknown), and delivery labels never claim more than the spoke can
444
+ confirm (hooks and screen: queued). A `409 unsupported` answer shows a toast naming what is missing.
445
+ - **Claude Code** (`packages/claude-code`). `hub claude [claude args…]` runs Claude Code with the hub plugin through
446
+ `--plugin-dir` (never editing `~/.claude`) and starts the per-user daemon (`hub claude-code daemon`, evidence
447
+ `hooks`) on a Unix socket in a private directory. The hook bridge (`hub claude-code hook`) fails open. Sessions
448
+ are `ses_cc<uuid hex>`; tool names map to hub actions (Bash → `shell`, Edit/Write → `edit`, Read → `read`,
449
+ `mcp__s__t` → `s_t`). Permissions wait for the hub in the PermissionRequest hook (`HUB_CLAUDE_PERMISSION_WAIT`,
450
+ default 120 s; 0 reports only), racing Claude Code's own dialog; deny reasons reach the model. Messages arrive at
451
+ the next tool boundary or prompt (`queued`), and an idle session is woken through an asyncRewake Stop hook
452
+ (`turn`, framed as a system notification); `hub claude -p` turns that hook off. Stop ends the running turn at its
453
+ next tool hook (denying the pending call). Esc interruptions are inferred from the next prompt or notification.
454
+ Session tokens reach the Bash tool's shells through the SessionStart env file; act verbs go through the plugin's
455
+ `hub` MCP server, with the act token kept in the daemon. Prompt references are attached as context. With the
456
+ `claude` CLI found, the daemon also runs hub launches headless (`-p --input-format stream-json`) in node
457
+ workspaces, taking turns and interrupts there.
458
+ - **Codex** (`packages/codex`). `hub codex [codex args…]` runs Codex with its shared app-server daemon on for that
459
+ run (`--enable daemon_auto_start`; `~/.codex` is never written) and starts the per-user spoke (`hub codex-spoke
460
+ serve`, evidence `rpc`, `turnEvents`). The spoke attaches to the daemon's threads (TUI sessions included) as one
461
+ client among others, and runs its own `codex app-server` over stdio for threads the hub starts. Sessions are
462
+ `ses_cx<thread uuid hex>`. Turns (`turn/start`), steer (`turn/steer`), interrupt and stop (`turn/interrupt`),
463
+ approvals (first answer wins; `gone` when the TUI answered first), messages (`turn/steer`, `turn/start`, or
464
+ `thread/inject_items` without waking; each message once, since Codex's queue does not deduplicate) and launches
465
+ (`thread/start`). Settings `HUB_CODEX_BIN`, `HUB_CODEX_DAEMON_SOCKET`, `HUB_CODEX_CONFIG`.
466
+ - **Presets** (`packages/preset`). `hub preset list | check | run` with the official presets `aider` and `goose`:
467
+ the harness runs in a new pane on a private tmux server (`-L hub-preset`; your own only with `--current`), prompts
468
+ and messages are pasted (answered `timeout` and `queued`: never confirmed), stop and interrupt send Ctrl-C, and
469
+ nothing but the native session ID and the pane's liveness is read off the screen. Never permission, form or
470
+ steer; never idle. Sessions are `ses_pr<uuid hex>`.
471
+ - **Services.** `hub claude-code install | uninstall [--dry-run] | status` and `hub codex-spoke install | uninstall
472
+ [--dry-run] | status` write a launchd agent or systemd user unit for the daemon and the spoke from the templates
473
+ in `scripts/`, like `hub node install`: the load commands are printed, never run.
474
+ - **Nodes and launches.** The host runtime reports the harnesses it offers: `opencode` always, `claude-code` when
475
+ the `claude` CLI is found (`HUB_CLAUDE_BIN`, else PATH), others through node.json `harnesses` (`{"codex": true}`,
476
+ `{"claude-code": false}`). `hub launch --harness claude-code | codex` places launches on such nodes
477
+ ([docs/launches.md](docs/launches.md#harnesses)).
478
+ - **Spoke SDK** (`packages/spoke-sdk`, shipped inside `@huynle/hub`): the connection, enrollment, session tokens,
479
+ `DeliveryLedger`, prompt envelopes and references extracted from the OpenCode spoke (its files re-export them);
480
+ reversible session ID mapping (`hubSessionID`, `nativeSessionID`); `buildHarnessHello`; and `HarnessSpoke`, which
481
+ announces exactly the ops it has handlers for, refuses the rest and unknown sessions, sends validated events and
482
+ the sessions frame, keeps tokens apart in memory and admits each message once.
483
+ - **Testkit** (`packages/testkit`, development only): a fake hub and spoke client, a stub model server (OpenAI chat
484
+ completions, Anthropic Messages, OpenAI Responses; text and tool calls), a deny-all proxy for live harness tests,
485
+ and the harness driver protocol v1 (`serveDriver`, `spawnDriver`).
486
+ - **Conformance** (`packages/conformance`, development only): `hub-conformance spoke --driver "<command>"` runs a
487
+ spoke through its driver against a recording hub and checks the hello, feature honesty, events, deliver
488
+ idempotency and modes, permission replies, token hygiene, reconnect and frame limits (table or `--json`).
489
+ OpenCode, Claude Code, Codex and preset spokes all pass; a reference spoke has one broken variant per check.
490
+ - **Scaffold**: `bun packages/spoke-sdk/scaffold.ts <name> <dir> [--namespace xy]` writes a minimal `HarnessSpoke`
491
+ spoke and its conformance driver for a new harness; the output passes the conformance suite as written.
492
+ - **Tests**: `test/e2e-phase7.test.ts` (every spoke on one machine, honest delivery, acting, unsupported answers,
493
+ claude-code and codex launches, tenancy) and `test/e2e-phase7-conformance.test.ts`; opt-in live tests against the
494
+ real CLIs with stub models, throwaway config dirs and a deny-all proxy (`HUB_IT_CLAUDE=1`, `HUB_IT_CODEX=1`).
495
+ - **Docs**: [docs/harnesses.md](docs/harnesses.md) (support matrix, evidence levels, setup, known limits, threat
496
+ model) and [docs/spoke-sdk.md](docs/spoke-sdk.md) (writing a spoke, the event subset, conformance, testkit).
497
+
498
+ ### Cluster mode
499
+
500
+ The hub can run as several pods behind one load balancer, sharing Postgres and Valkey (or Redis), with no sticky
501
+ sessions and rolling updates without downtime ([docs/cluster.md](docs/cluster.md)). The single-process hub is
502
+ unchanged (its files stay byte-identical) and remains the default.
503
+
504
+ - **Configuration**: `HUB_DATABASE_URL` (`postgres://`) and `HUB_VALKEY_URL` (`redis://`, `rediss://`, `valkey://`,
505
+ `valkeys://`), or hub.json `cluster {database, valkey}`; both or neither. `HUB_POD_ID` names the pod (default the
506
+ hostname). Cluster mode requires `HUB_TOKEN_KEYS` (shared by every pod) and `HUB_AUTH=oidc`, and refuses pairing
507
+ mode. The URLs are never logged; a readable hub.json's URLs with a password are ignored.
508
+ - **Shared state**: users, machines, devices, settings, push subscriptions, the audit log and agent data live in
509
+ Postgres (`hub_state`, `hub_claims`, `hub_schema`); pods cache them, tell each other what changed and reload every
510
+ 30 s; writes are version-checked. Codes (pairing, login, link, enrollment), OIDC sign-in state, rate limits, push
511
+ cooldowns and answered-request markers live in Valkey, so a code issued on one pod works on another. No new
512
+ dependencies (Bun's own Postgres and Redis clients).
513
+ - **Routing across pods**: each pod records its spoke and node connections in Valkey and serves requests for them
514
+ on `pod.<id>.rpc`; a request for a session on another pod is forwarded there (tenant checked again by the owning
515
+ pod). Every pod sees every connection through a shared cluster view, so status, `/api/agent/*`, placement and turn
516
+ routing work wherever a request lands. Pods re-check their sockets every 30 s and close those of revoked machines
517
+ and disabled users.
518
+ - **Live events across pods**: console SSE (`/api/events`), `/api/agent/watch`, message and launch feeds reach every
519
+ pod; events (except streamed text) go to a per-user Valkey stream, so `Last-Event-ID` resumes on any pod.
520
+ - **Leader jobs**: decision deadlines, message re-wakes, the launch supervisor and the webhook sender run on one pod
521
+ at a time under fenced 15 s leases, failing over when a pod drains or dies; a session's turn and message flush take
522
+ cluster-wide leases.
523
+ - **Draining and readiness** (every mode): `GET /readyz` answers 503 while draining and, in cluster mode, when
524
+ Postgres or Valkey does not answer within 2 s (`/healthz` stays the liveness check). SIGTERM drains: new
525
+ WebSockets are refused (503, `Retry-After`), spokes and nodes get `bye {reason: "draining", retryAfterMs}` and
526
+ close code 4006 once their requests are answered, leader leases are released, SSE streams end with `retry:`, and
527
+ requests in flight get up to 50 s. A second signal stops at once (exit 1); SIGINT stops without draining.
528
+ Protocol (still version 1): `bye.retryAfterMs` (0–300000, optional); the OpenCode spoke and `hub node` reconnect
529
+ after a random 0–`retryAfterMs` instead of their usual backoff.
530
+ - **`hub-server migrate`** applies the Postgres schema (idempotent, under an advisory lock); pods never migrate and
531
+ refuse an older schema. **`hub-server import --state <dir> [--dry-run] [--force] [--print-token-keys]`** copies a
532
+ single-process hub's state (state.json and agent data) into Postgres: idempotent, conflicts reported (overwritten
533
+ only with `--force`), machines and devices keep their tokens; `--print-token-keys` prints `token-keys.json` as the
534
+ `HUB_TOKEN_KEYS` value, which is never stored.
535
+ - **Kubernetes manifests** in `deploy/k8s/`: Deployment (2 replicas, topology spread, `HUB_POD_ID` from the pod
536
+ name, `/readyz` and `/healthz` probes, `preStop` sleep, 60 s grace period, non-root, read-only root filesystem),
537
+ Service, Ingress example and Traefik notes for long WebSocket/SSE timeouts without buffering, Secret template,
538
+ PodDisruptionBudget, migrate Job, NetworkPolicy, Kustomization and a Flux example with SOPS; validated offline
539
+ (`kubeconform -strict`, `test/deploy-k8s.test.ts`).
540
+ - **Tests**: backend contract suites run against memory and file backends by default and against real Postgres 16
541
+ and Valkey 8 containers with `HUB_IT_CLUSTER=1`; in-process multi-pod suites on shared memory backends run in
542
+ the default CI.
543
+
544
+ ### Other changes
545
+
546
+ - **Auto-name**: let OpenCode name a session with its Title agent, from the console. **Auto-name** in every
547
+ session `⋯` menu (table, phone list, sidebar, session title) and a ✨ button next to the inline rename field;
548
+ the title shows "Naming…" until OpenCode answers, then "Renamed to …" or "OpenCode kept the title".
549
+ `POST /api/sessions/:id/rename {auto: true}` → spoke op `autoName`, which calls `session.update` with an empty
550
+ title on the process's own server (the plugin API's update would set an empty title) and waits up to 20 s for
551
+ `session.renamed` (202 `{pending}` after that). Spokes now list their optional features in the hello
552
+ (`features: ["autoName"]`); a process with an older plugin gets "Update the Hub plugin to use Auto-name"
553
+ instead of a request it would drop.
554
+
555
+ - **Question answers no longer lost**: a question card in the console kept its answers in components that were rebuilt
556
+ whenever any live update arrived (every session change sends a fresh catalog), so choices and typed text were
557
+ silently reset and Submit sent every question to OpenCode as "Unanswered". Cards are now keyed by request ID and
558
+ fields by key, and a question form with nothing chosen is refused with a hint (Dismiss skips it).
559
+ - **Voice mode: progress cues play as loud as the spoken replies.** Short cue phrases ("On it.", "Hang on." …)
560
+ came out of TTS up to ~4 dB louder or quieter than replies (measured: −15 to −22 LUFS against replies around
561
+ −19.5). Each phrase is now normalised while it is prefetched: measured (BS.1770 gated loudness), trimmed of
562
+ silence beyond 80 ms (cues start sooner), gained to the replies' loudness with peaks limited to −1 dBFS, and
563
+ cached as WAV. The target is the running median of measured replies per TTS voice (completed sentences decoded
564
+ when idle; −19.5 LUFS until then). The cue cache moved to `hub-cues-v2`, so old un-normalised cues are fetched
565
+ again once. Both audio elements play at volume 1. Measured with the default voice: cue spread around the reply
566
+ median went from −2.0…+4.8 dB to within 0.1 dB; normalising adds ~8 ms (max 60 ms) per phrase at prefetch and
567
+ nothing at play time.
568
+ - **No more false "finished" notifications; pick exactly which notifications each device gets.** "Finished"
569
+ fired on every turn of a root session, but in OpenCode v2 each background subagent that completes starts a
570
+ short continuation turn in its parent (a synthetic message), as do steering and Voice turns: in a real session
571
+ 82 of 114 "finished" pushes came while subagents were still running. It also fired while you were looking at
572
+ the session. Now (see [docs/notifications.md](docs/notifications.md)):
573
+ - **All done** (new, on by default) replaces it: once per run, when the root's last turn succeeded and the
574
+ whole tree stayed settled for 15 s (no subagent or shell job running, no question or permission pending,
575
+ no hub turn). A continuation turn within the 15 s cancels it.
576
+ - New per-device kinds, off by default: **Turn finished** (the old every-turn behaviour), **Stopped**
577
+ (shutdown/inactivity), **Retrying**, **Subagent finished/failed**, **Script finished/failed** (background
578
+ shell or terminal exit code), **Long tool finished** (≥ 30 s). Failed (with the reason) is on by default.
579
+ - Titles say the kind: "KVM Rewrite · All done", "· Subagent S3 macros finished", "· Script exited 1";
580
+ progress kinds replace each other quietly.
581
+ - Run notifications are no longer sent while a device shows the session (questions and permissions are).
582
+ - Settings → Notifications: kinds grouped (Needs you, Session, Subagents, Scripts and tools, Voice, Activity
583
+ digest) with a one-line description each, a "You'll get: …" summary, and a test of a chosen kind
584
+ (`POST /api/push/test {kind}`).
585
+ - Prefs migrate on start: `finished: true` becomes All done (not every turn); failed, activity and the rest
586
+ are kept; new kinds start off. The API still accepts `finished` (→ `done`) and `blocked`.
587
+
588
+ - **The console keeps a session's conversation in order**: subagents no longer bunch together at the bottom of
589
+ the conversation, and thoughts no longer jump below the text or tools that came after them. Rows used to be
590
+ sorted by their timestamps, which come from different clocks (OpenCode's message and part times, the hub's,
591
+ the phone's), and a finished subagent got the time the page loaded. Now:
592
+ - Every timeline row from the spoke carries `pos`, its position in the session (message order, then part
593
+ order), and the console orders rows by it. Tool rows of `subagent`/`task` carry `childSessionID` (from the
594
+ tool's metadata, else its output) and `title`; tool rows `callID`; text rows `ordinal`.
595
+ - A subagent row stands in place of the tool call that spawned it, also after it finished and after a reload.
596
+ When the spawn is in an older page that is not loaded yet, the subagent shows at the top of the loaded
597
+ history and moves to its spawn when that page loads.
598
+ - Live thoughts, text, tools, sent messages and spawned subagents appear in arrival order after the history,
599
+ and when the stored rows arrive (reconnect) they replace the live ones in place.
600
+ - The spoke forwards a subagent tool's child session (`session.tool.progress`, sent as a compact
601
+ `session.tool.called {id, childSessionID}`, which older hubs accept); SSE `activity` items carry
602
+ `childSessionID` and `delta` events the text part's `ordinal`. All new fields are optional: older spokes
603
+ and hubs keep working (rows without `pos` keep the order of their pages).
604
+
605
+ - **Sessions open on their newest messages, and older ones load while you scroll up**: the console used to load
606
+ a conversation oldest first, 200 rows at a time and at most 2000 rows, before showing anything, so a long
607
+ session opened slowly and could miss its newest messages. It now asks for the newest 80 rows (one request),
608
+ shows them at the bottom, and loads the page before them as you get near the top ("Loading earlier
609
+ messages…", then "Beginning of conversation"; a failed page offers Retry). What is on screen stays in place
610
+ while older rows are added above it, and live replies still arrive at the bottom. Measured on a mock session
611
+ with ~1000 rows: 1 request and 13.7 KB instead of 6 requests and 178 KB, and only 80 rows rendered at first.
612
+ - API: `GET /api/sessions/:id/timeline?limit=N&order=desc` returns the newest rows with an `older` cursor;
613
+ `?before=<older>` returns the page before it. Cursors are row keys, so they stay valid while new messages
614
+ arrive. The forward `?cursor=` mode is unchanged. Consoles talking to older spokes fall back to loading the
615
+ whole history as before.
616
+ - The spoke reads OpenCode's history once per first page and serves the following pages from a short-lived
617
+ per-session cache (30 s, dropped on any event of the session), instead of reading the whole history again
618
+ for every page.
619
+ - After a reconnect that missed events, the console reloads the newest page. It keeps the older pages already
620
+ loaded when the new page overlaps them; otherwise it starts over from the newest page.
621
+
622
+ - **Voice mode tells the truth with the screen off**: Android Chrome usually stops the microphone when the
623
+ screen turns off, while the "Voice mode on · listening" notification stayed. Voice mode now notices (no
624
+ audio frames for 1.5 s, the mic track muted or ended, the AudioContext suspended) and says so: the Voice
625
+ bar shows "Paused — mic stopped by the phone (screen off). Unlock to resume.", the notification turns into
626
+ "Voice mode paused · unlock to talk" (End and Open kept; updated at once), the lock-screen controls show
627
+ paused and a short falling tone plays. On unlock it resumes the audio, asks for the microphone again if
628
+ needed (or offers "Tap to start Voice mode"), retries with backoff and plays a rising tone; it says
629
+ "listening" only once audio arrives. While hidden it keeps trying (audio resume, silent loop). The Settings
630
+ text for "Keep listening when the screen is off" now says it is best effort.
631
+ - **Smoother live connection**:
632
+ - **Resume instead of reload**: every SSE message carries an id; a console that reconnects (phone unlocked,
633
+ app switched back, network blip) sends `Last-Event-ID` (or `?last=`) and the hub replays what it missed from
634
+ a per-user buffer (last 2000 events or 5 minutes, one fresh catalog for any catalog changes). When the gap
635
+ is not covered (hub restarted, too long away) it sends the catalog and a `resync` marker, and the open
636
+ session refetches its conversation, status and details.
637
+ - **No bandwidth while you are not looking**: a page hidden for 30 s closes its stream (Voice mode keeps it
638
+ open); showing it again (or coming back from the back/forward cache) reopens it at once with the last id. A
639
+ stream silent for 45 s (no message, no heartbeat) is reopened. Heartbeats are now `event: ping`.
640
+ - **Lite streams**: the console asks for `?lite=1&session=<id>`: streamed text and reasoning only for the
641
+ session on screen (everything for Voice mode). Measured on an idle hub: ~50 bytes/min after the first
642
+ catalog; a busy session over a minute: 9.3 KB full vs 7.4 KB lite (the rest is catalogs, status and activity).
643
+ - The top bar shows "Reconnecting…" and then "Updated" for a moment instead of a reload.
644
+ - **Activity updates** (opt-in push, per device; Settings → Notifications shows a hint until it is on): one
645
+ quiet, self-replacing notification per session with what happened — subagents started/finished, tools of
646
+ 30 s or more, background shells and terminals that ended (with exit code), the session going idle — at most
647
+ one per session per interval (1–60 minutes, default 5; "3 tools · explore finished · Finished"). Quiet
648
+ hours and muted sessions apply; nothing while a device shows the session.
649
+ - **Running detection**:
650
+ - Sessions running a script are working: the spoke forwards OpenCode's `session.status` (busy/retry/idle) and
651
+ the shells and terminals sessions start (compact: id, session, command ≤ 200 chars, status, exit code). A
652
+ session works while its run, a busy or retrying status, or a running shell/terminal lasts.
653
+ - **Retrying** shows with its reason ("Retrying (attempt 2): Rate limited").
654
+ - **Background jobs**: a count next to the status in the table and sidebar, "· 1 job" in the session header,
655
+ and a **Jobs** card in the rail (command, running time, exit code for a minute after it ends).
656
+ - **Seeding**: on connect, after a hub reconnect and after OpenCode's event stream comes back, the spoke asks
657
+ its own server what runs (`session.active`, `shell.list`) so sessions already running show as working.
658
+ - **Nicer replies**: the console renders more of the Markdown agents write, still as elements (never raw HTML;
659
+ HTML in a reply shows as text):
660
+ - **GFM tables** (column alignment, formatting in cells, scrolling sideways on narrow screens), **nested lists**
661
+ (by indentation, numbered and bulleted mixed), **task lists** (disabled checkboxes), list items with several
662
+ paragraphs or a code block, headings, lists and code **inside quotes**, **setext headings**, `~~strike~~`,
663
+ `==highlight==`, **autolinks** (bare http(s) URLs and `<url>`), line breaks (a single newline in a paragraph
664
+ is a break, as agents write), backslash escapes, and images as a link named by their alt text (never loaded).
665
+ - **Code blocks**: language label, **Copy** (with "Copied"), **Wrap** toggle (else long lines scroll), and
666
+ syntax highlighting for TypeScript/JavaScript/TSX, JSON, bash/sh, Python, Go, Rust, YAML, diff (added and
667
+ removed lines colored), SQL, CSS, HTML/XML and Markdown: a small built-in tokenizer loaded with the first
668
+ code block (3.3 KB gzipped chunk; the main chunk grew 5.7 KB gzipped).
669
+ - **While a reply streams**, the block being written closes what it opened (`**bold` shows bold, a table
670
+ header alone is a table, a link whose address is still arriving shows its label), and blocks update in place
671
+ instead of being rebuilt.
672
+ - **Text size and pinch-to-zoom**: all console text sizes follow one `--font-scale` (80–180%). Set it in
673
+ **Settings → Appearance → Text size** (with Reset), or **pinch** the conversation or the session list: two
674
+ fingers on a phone, a trackpad pinch on desktop (ctrl+wheel, Safari gesture events). The pinch only changes the
675
+ text there; elsewhere (and ⌘+/⌘−) the browser zooms as before. Kept per device and applied before the first
676
+ paint. On touch screens text fields are at least 16 px, so iOS no longer zooms in when one gets focus.
677
+
678
+ - **Voice mode hears you, not the room**:
679
+ - **On-device speech detection**: Silero VAD v5 (MIT) runs in the browser through onnxruntime-web (MIT; wasm,
680
+ single-threaded, in a Web Worker) on 16 kHz, 32 ms chunks: speech starts at probability ≥ 0.6 for 250 ms and
681
+ ends after 350 ms below 0.35. Typing, fans, clatter, beeps and hum no longer start a turn. The energy
682
+ detector stays as a pre-gate (quiet chunks skip the model) and as the fallback while the model loads or if
683
+ it fails (logged once). The model and wasm load when Voice mode first starts, as hashed assets outside the
684
+ main JS chunk; the hub now serves `/assets/*` with `cache-control: public, max-age=31536000, immutable`,
685
+ `.wasm` and `.onnx` with their types, and compresses big hashed assets (Brotli or gzip) when accepted.
686
+ Measured about 1 ms per 32 ms chunk; segments end within one chunk of the energy detector.
687
+ - **Echo guard**: a mono mic track with the browser's echo cancellation, noise suppression and auto gain; while
688
+ a reply or a progress cue plays (and 400 ms after) a barge-in needs probability ≥ 0.85 and 8 dB more energy.
689
+ - **Settings → Voice → Mic sensitivity** (Low / Normal / High, per device, applies at once): model thresholds,
690
+ speech needed to start, and the energy margins.
691
+ - **Noise transcripts dropped**: "Thank you.", "you", "Thanks for watching!", "Subtitles by …", "[Music]",
692
+ punctuation-only and repeated-word transcripts, and one or two words from under 400 ms of speech (unless a
693
+ command or answer like "yes", "no", "stop", "go ahead", "cancel that") never reach the agent; the Voice bar
694
+ shows them dim for a moment ("ignored: …").
695
+ - **Whisper's no-speech rating**: with a Whisper model the hub asks for `verbose_json` and drops segments with
696
+ `no_speech_prob` > 0.6 (falls back to plain requests for providers that refuse it).
697
+
698
+ - **Voice mode: varied progress cues**: instead of the same "Working on it.", "Started a subagent." and
699
+ "Still working." every time, each cue has several short, natural variants ("On it.", "Give me a sec.",
700
+ "I'll hand that off.", "Still on it.", "Almost there." …). A shuffle-bag picks them: every variant once
701
+ before any repeats, never the same one twice in a row, preferring one whose audio is already cached (else
702
+ the short tone, as before). All variants are prefetched when Voice mode starts, 2 at a time, one of each
703
+ kind first, and cached per TTS voice as before; ending Voice mode cancels a prefetch still running (aborts
704
+ its requests), so it never holds the browser's connections.
705
+
706
+ - **Voice mode: streaming speech and several tasks at once** (release 2 of 3):
707
+ - **Replies play while their audio downloads**: `/api/speak` passes the TTS provider's MP3 through as it
708
+ arrives (nothing is buffered on the hub; a cancelled request aborts the upstream one), and the phone plays
709
+ each sentence through MediaSource while it is still downloading. Browsers without MP3 in MediaSource (some
710
+ iOS Safari versions) play each sentence once complete, as before. Prefetching two ahead, barge-in, Voice
711
+ speed and the cached cue phrases work as before. New turn step `hub-voice:first-byte` (the Voice bar tooltip
712
+ shows "first byte" between "first reply" and "audio").
713
+ - **Parallel workers**: the voice agent starts one background worker per independent request, all in the same
714
+ turn ("check the deploy and update the changelog" → two workers), names each in a few words ("Deploy
715
+ check"), and keeps sending follow-ups for a running task to the same worker.
716
+ - **Task board**: every voice turn carries a live list of the agent's workers (title, sessionID, working /
717
+ blocked on what / done with its result / failed with the reason / stopped, and for how long; at most 8, of at
718
+ most 200 characters each), so "what's running?" or "how's the deploy going?" is answered without a tool call.
719
+ - **Stop one task by voice**: new tool `hub_task_stop` ("cancel the deploy one" → that worker and what runs
720
+ below it; "stop all the workers" → `all: true`). It only reaches workers of the session calling it, never the
721
+ voice agent itself. The phone's stop words still interrupt the agent's turn at once.
722
+ - **Announcements**: with several workers in play, a finished worker's result starts with its task's name
723
+ ("Deploy check finished: …"); a result the voice agent already said is not read again; a failed worker is
724
+ announced once with its reason, a permission request or question once ("Cleanup needs your permission.").
725
+ Announcements queue after the sentence playing.
726
+ - **Barge-in** between two sentences of a reply still being written now also drops the rest of that reply
727
+ (before, only a reply that was playing went quiet). Barge-in never interrupts the agent or its workers.
728
+ - **Fix**: Voice mode's progress-cue clock and the endpointer's hold timer called `setTimeout` as a method,
729
+ which browsers reject ("Illegal invocation"): progress cues could stop working and an unfinished-sounding
730
+ utterance was never sent after the hold.
731
+
732
+ - **Faster Voice mode** (release 1 of 3):
733
+ - **Speak while it writes**: the hub cuts a root reply's streamed text into sentences and sends a `say` per
734
+ completed sentence (new `seq`, counted per text part from 0) right after its delta; `session.text.ended`
735
+ flushes the rest, so the first sentence plays while the agent is still writing instead of after the whole
736
+ reply. Code blocks are one piece (spoken as "(code omitted)"); abbreviations, decimals and file names stay
737
+ together. One mechanism: each sentence is sent once, also with several processes reporting the session; an
738
+ interrupted run or Voice mode ending drops the unspoken rest. A subagent's final result is spoken as before
739
+ (one `say` without `seq`). The phone plays says in order, once, and synthesizes up to two sentences ahead;
740
+ barge-in also silences the rest of the reply it interrupted. The push for a reply nobody watches is sent
741
+ once with the whole text. Clients that ignore `seq` still get each sentence's text.
742
+ - **Instant acknowledgement**: the "heard" tone plays the moment you stop speaking (when the segment goes to
743
+ transcription), not when the agent receives the utterance; it does not play twice. Typed text in Voice mode
744
+ still gets it on delivery.
745
+ - **"Working on it." after 2.5 s** (was 5 s), counted from the end of your speech, and progress cues are
746
+ event-driven (one timer for the next deadline instead of a 1 s poll).
747
+ - **Cue phrases ready at once**: "Working on it.", "Started a subagent." and "Still working." are synthesized
748
+ in parallel when Voice mode starts and kept in the browser's Cache Storage per TTS voice (provider, model,
749
+ voice, speed, server), so later sessions need no synthesis; a ready phrase starts playing immediately, and
750
+ one not ready yet plays a short tone instead of arriving late.
751
+ - **Tighter turn-taking**: a segment ends after 350 ms of silence (was 550 ms), speech must last 300 ms to
752
+ count (was 250 ms), and an unfinished-looking transcript is held 0.8 s (was 1.5 s); all in `VOICE_TIMINGS`.
753
+ - **OpenRouter speech-to-text** uploads the audio as an OpenAI-style multipart file (documented by
754
+ OpenRouter) instead of base64 JSON: a third less to send, no encoding.
755
+ - **No model lookup per utterance**: the spoke remembers each session's model (from OpenCode's
756
+ `session.model.selected`, its own switches and lookups) instead of asking OpenCode on every utterance with a
757
+ Voice model set; it asks again after its event stream reconnects.
758
+ - **Turn timing**: `performance.mark`s for speech end, transcript, accepted, first say and first audio, and the
759
+ Voice bar's state tooltip shows "last turn: 1.8 s to first audio" with the steps.
760
+
761
+ - **Resizable sidebar and Details rail** (desktop): drag the thin handle on the sidebar's right edge or the
762
+ rail's left edge (it lights up on hover and while dragging). Sidebar 180–480 px (default 235), rail 220–640 px
763
+ (default 260); both give way so the transcript column keeps at least 420 px, and are re-fitted when the window
764
+ resizes. The handles are focusable separators: Arrow keys resize by 16 px (Shift: 64 px), Home/End jump to the
765
+ minimum/maximum, a double-click resets the default. Dragging the sidebar well past its minimum collapses it (as
766
+ ⌘B does) and expanding it restores the last width. Widths are remembered per device (`hub.panels` in local
767
+ storage); phones keep the drawer and bottom sheet. Long session titles in the sidebar still end in an ellipsis,
768
+ with the full title as a tooltip.
769
+
770
+ - **Rename sessions**: **Rename…** in a session's `⋯` menu (table, sidebar, phone list, session title menu), or
771
+ a click on the session title in the top bar, edits the title in place (Enter saves, Escape cancels). The title
772
+ is set in OpenCode itself (`session.update`), so terminals and other consoles see it; the console shows it at
773
+ once (also while older catalog updates arrive) and reverts with a message if renaming fails. API: `POST /api/sessions/:id/rename {title}` → `{title}`
774
+ (trimmed, 1–200 characters), through the owning process with a fallback to other processes that report the
775
+ session; Voice-off sessions can be renamed too. New spoke op `rename`: a machine still on an older plugin
776
+ does not answer it, so renaming its sessions fails after the 15 s request timeout until it is updated.
777
+ - **Archive sessions** (replaces Hide): **Archive** / **Unarchive** in a session's `⋯` menu, with **Undo**.
778
+ Archived sessions leave the table, sidebar groups, Recents and Running on all your devices (synced through
779
+ the view prefs and their SSE `prefs` event); the **Archived** chip (formerly Hidden; a saved Hidden filter
780
+ opens Archived) lists them. An archived session that needs you still shows under **Needs you** so a question
781
+ is not missed. Bulk: checkboxes in the table (and select all shown) with **Archive** / **Unarchive**, and
782
+ **Archive inactive…** (more than N days without activity, 30 by default; not running, pinned or open in a
783
+ terminal) after a confirmation with the count. Nothing is deleted and OpenCode is not changed: OpenCode has
784
+ no archive API yet, so archiving is the hub's own (sessions archived in OpenCode itself are still never
785
+ listed). Sessions hidden before are archived now: the stored list is the same `hiddenSessions`, which now
786
+ holds up to 5000 sessions (`PUT /api/prefs/view`; other lists stay at 500), the oldest archives making room.
787
+ - **Composer**: a sent message (typed or dictated) no longer comes back into the input with a raw "Failed to
788
+ fetch" under it when the connection of the turn's request drops after the hub took the turn (e.g. a proxy
789
+ cutting the long request). If the session is running, the send counts as delivered: the input and the
790
+ session's draft stay empty, a note says the connection was lost and the reply streams into the transcript.
791
+ Only when the session never starts is the text put back (as for any refused send). Network-level failures
792
+ of console API calls now read "Can't reach the hub: the connection dropped…" instead of the browser's
793
+ "Failed to fetch".
794
+ - **Typed turns no longer hold a request open for the whole turn**: the console sends `POST
795
+ /api/sessions/:id/turn` with `Prefer: respond-async`; a turn still running after 1.5 s is answered 202
796
+ `{mode: "turn"}` and its result (failure notes, a reply that did not stream) arrives as a new SSE `turn`
797
+ event. Before, the request stayed open until the turn ended, and proxies cut it (Traefik's default
798
+ `readTimeout` is 60 s), which the console showed as "Failed to fetch". Quick results and refusals (busy,
799
+ unknown session, spoke errors) answer as before; clients without the header keep the blocking answer.
800
+ - **Console**: table rows, sidebar rows and an open `⋯` menu no longer re-render (and close) when an unrelated
801
+ catalog update arrives; unchanged rows keep their DOM.
802
+
3
803
  ## 0.6.1
4
804
 
5
805
  - **GitHub installs**: an install without the built web app (`web/dist`, e.g. `opencode plugin add