@huynle/hub 0.9.1 → 0.10.0

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 (143) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +153 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +532 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +69 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent/webhooks.ts +5 -2
  60. package/src/central/agent-data.ts +43 -1
  61. package/src/central/backend/memory-counters.ts +2 -2
  62. package/src/central/backend/types.ts +5 -2
  63. package/src/central/backend/valkey-counters.ts +5 -4
  64. package/src/central/client-address.ts +16 -0
  65. package/src/central/concierge/board-render.ts +52 -1
  66. package/src/central/concierge/board-routes.ts +25 -2
  67. package/src/central/concierge/board.ts +11 -1
  68. package/src/central/concierge/context.ts +29 -8
  69. package/src/central/concierge/grant-request.ts +216 -0
  70. package/src/central/concierge/prompt.ts +49 -2
  71. package/src/central/concierge/session-routes.ts +3 -0
  72. package/src/central/config.ts +88 -5
  73. package/src/central/context-cards/budget.ts +121 -0
  74. package/src/central/context-cards/changes.ts +29 -0
  75. package/src/central/context-cards/config.ts +145 -0
  76. package/src/central/context-cards/engine.ts +379 -0
  77. package/src/central/context-cards/inputs.ts +111 -0
  78. package/src/central/context-cards/pickup.ts +134 -0
  79. package/src/central/context-cards/registry.ts +89 -0
  80. package/src/central/context-cards/routes.ts +37 -0
  81. package/src/central/context-cards/service.ts +367 -0
  82. package/src/central/context-cards/store.ts +67 -0
  83. package/src/central/context-cards/types.ts +88 -0
  84. package/src/central/fetcher.ts +10 -2
  85. package/src/central/front-relays.ts +423 -0
  86. package/src/central/http.ts +517 -56
  87. package/src/central/human-activity.ts +453 -0
  88. package/src/central/llm/chat.ts +133 -0
  89. package/src/central/push-target.ts +11 -2
  90. package/src/central/registry.ts +162 -9
  91. package/src/central/speech/service.ts +9 -0
  92. package/src/central/spoke-ws.ts +13 -0
  93. package/src/central/store.ts +9 -12
  94. package/src/config.ts +30 -13
  95. package/src/index.ts +45 -43
  96. package/src/rpc.ts +33 -4
  97. package/src/spoke/concierge-agent.ts +1 -1
  98. package/src/spoke/conversation.ts +6 -75
  99. package/src/spoke/deliver.ts +18 -8
  100. package/src/spoke/hub-rpc.ts +7 -2
  101. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  102. package/src/spoke/hub-tool-title.ts +89 -0
  103. package/src/spoke/hub-tool.ts +56 -18
  104. package/src/spoke/session-context.ts +10 -2
  105. package/src/spoke/spoke.ts +209 -238
  106. package/src/spoke/summary.ts +36 -1
  107. package/src/spoke/taskboard.ts +5 -6
  108. package/src/spoke/voice-front.ts +323 -30
  109. package/src/spoke/who-teams.ts +51 -2
  110. package/src/tui-messaging.ts +24 -10
  111. package/src/tui.ts +6 -6
  112. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  113. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  114. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  115. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  116. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  117. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  118. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  119. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  120. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  121. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  122. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  123. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  124. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  125. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  126. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  127. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  128. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  129. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  130. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  131. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  132. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  133. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  134. package/web/dist/index.html +6 -6
  135. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  136. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  137. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  138. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  139. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  140. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  141. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  142. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  143. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
package/README.md CHANGED
@@ -41,8 +41,10 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
41
41
  - **Running detection**: a session counts as working while OpenCode reports it busy or retrying, or while a
42
42
  shell or terminal it started still runs (e.g. a background `sleep 60 &` or build script), also when it was
43
43
  already running before the plugin started.
44
- - **Voice** (optional feature, `/voice`): dictation and a hands-free conversation mode with a voice agent
45
- that answers in short spoken sentences on a fast model and hands real work to background subagents.
44
+ - **Voice** (optional feature, `/voice`): dictation and a hands-free conversation mode. Voice mode always talks
45
+ through a **voice front**: a hidden, fast voice agent that says what it understood and relays your request to
46
+ the session, which keeps its own agent, model and tools (Settings → Voice → Voice front; on by default, off
47
+ talks to the session directly).
46
48
  Bring your own speech: OpenRouter or any OpenAI-compatible STT/TTS service (including self-hosted).
47
49
  A developer **recording mode** (Settings → Voice, off by default) keeps your voice segments, transcripts,
48
50
  replies and timing on the hub for 7 days, private to you, with Export and Delete
@@ -58,6 +60,9 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
58
60
  - **Agent messaging**: sessions, scripts and you message each other through the hub (`hub msg @reviewer …`,
59
61
  `hub ask`, the OpenCode `hub` tool, `/msg`, `hub mcp`); asks to `@me` become Decisions you answer from the
60
62
  console ([Agent messaging](#agent-messaging)).
63
+ - **Session subscriptions**: a session follows others (`hub subscribe @builder --type state:idle`, a role, a
64
+ `#team` or its subagents) and gets one batched hub message per window to react to, waking it when idle, with
65
+ loop protection and rate limits ([docs/subscriptions.md](docs/subscriptions.md)).
61
66
  - **Launches**: you, a script or an app start a session on one of your machines (`hub launch --branch feat/x
62
67
  --prompt "…"`): a `hub node` daemon prepares a git worktree, the session gets its name, roles, labels and first
63
68
  prompt, and ends by itself when it is done, with signed webhooks for apps ([docs/launches.md](docs/launches.md)).
@@ -148,7 +153,7 @@ Then, in OpenCode:
148
153
  |---|---|
149
154
  | **`/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). |
150
155
  | **`/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. |
151
- | **`/who [project \| @@role]`** | Lists the hub's named and role-holding sessions (name, project, state, online, roles), asked as the session on screen. |
156
+ | **`/who [project \| #team \| @@role]`** | Lists the sessions the hub shows the session on screen: every online one (named or not; an unnamed one by its ID and title) and the named or role-holding ones while offline, with project, state, online and roles. A `You: @name · project` line heads the dialog (`You: ses_… (unnamed…)` until it has a name: ask the agent to name itself, or run `hub name @name --session ses_…` in a logged-in terminal; a `!` command gets no session token), its own row is marked `(you)` and listed first, then online before offline. |
152
157
  | **`/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. |
153
158
 
154
159
  ### Local: everything on this computer
@@ -181,9 +186,12 @@ Pre-configured machines: with `"server": "https://hub.example.com"` in
181
186
  `~/.config/hub/config.json`, `/hub` leads with **Connect to hub.example.com**.
182
187
  `/hub` → **Server** → **Set up again…** switches between local and a hosted hub later.
183
188
 
184
- Optional: put the voice agent on a fast model in `~/.config/hub/config.json`:
185
- `"voiceModel": "anthropic/claude-haiku-4-5"` (any `provider/model` you have). Subagents keep the
186
- session's model.
189
+ Recommended for Voice mode: put the voice front on a fast, paid model in `~/.config/hub/config.json`:
190
+ `"voiceModel": "anthropic/claude-haiku-4-5"` (any `provider/model` you have; a session's Voice model in the
191
+ console wins). Claude Haiku 4.5 measured fastest (0.7–0.8 s to the first token through OmniRoute), with Haiku 5.5
192
+ as the alternative. The front never thinks: the hub turns its model's thinking off, whatever the model or variant.
193
+ Avoid free models: when one runs out of quota the front fails and Voice mode talks to the session directly. The
194
+ session itself keeps its own agent and model.
187
195
 
188
196
  ## Set up a machine (one line)
189
197
 
@@ -360,7 +368,8 @@ foreground with `hub.json`; `scripts/io.github.huynle.hub.plist` is a launchd te
360
368
  **Security:** the hub can drive your OpenCode sessions, which can run tools. Every API call needs a
361
369
  paired device or a signed-in user; spokes need machine tokens; nothing trusts the client IP. For a public
362
370
  hostname, consider [sign-in with Google](#multi-user-sign-in-with-google) or an authenticating proxy in
363
- front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, `/.well-known/hub`, `/install.sh`,
371
+ front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, `/.well-known/hub`, `/connect/*` (connected apps' token
372
+ endpoints), `/.well-known/oauth-authorization-server`, `/install.sh`,
364
373
  `/install.ps1`, the manifest and icons reachable for spokes, sign-in, app discovery, [machine
365
374
  setup](#set-up-a-machine-one-line) and the PWA (and `/mcp`, bearer-authenticated, if MCP clients
366
375
  or gateways use [MCP over HTTP](#over-http-mcp)).
@@ -410,9 +419,14 @@ conversation mode and utterances (403 `not_exposed` for sessions not shared with
410
419
  | `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) |
411
420
  | `POST /api/sessions/:id/fork {messageID}` · `GET …/revert/preview?messageID=` · `POST …/revert {messageID, files?}` · `POST …/revert/restore` · `POST …/revert/commit` | fork before one of the session's user messages (201 `{sessionID, text, attachments}`), or stage, undo and commit a revert from it (409 `busy` while it runs); console only, audited; needs a spoke announcing `fork`/`revert` (409 `unsupported` otherwise). Contract: [docs/session-view.md](docs/session-view.md#fork-and-revert-a-message) |
412
421
  | `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) |
413
- | `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 |
422
+ | `GET /api/sessions/:id/summary` | context usage, the model the session runs on (`model`: chosen, else its latest reply's, else the default; with `source`), 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 |
423
+ | `GET /api/sessions/:id/context-cards` · `POST …/context-cards/:provider/refresh` | the session's [context cards](docs/context-cards.md) (`{cards, providers, enabled, configured}`), and running one provider now (`{force?}` → `{outcome: "generated", card}` · `{outcome: "skipped", reason}` · `{outcome: "failed"}`; 404 unknown provider; 429 with `Retry-After`: one per card per 30 s, 10 per user per minute). Live: SSE `{type: "contextCard", sessionID, provider, primary?, generatedAt}` (never the text) |
424
+ | `GET /api/context-cards/primary?sessions=a,b` | the primary context cards of up to 200 sessions (`{cards: {sessionID: card}}`): the session list's second line |
425
+ | `GET /api/settings/context-cards` · `PUT {enabled}` | your context card setting, whether the hub allows them and has a model (`{enabled, available, configured, model?, usage}`, never the key); off until you opt in |
414
426
  | `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 |
415
- | `POST /api/sessions/:id/read` · `GET /api/reads` | per-user read marks, synced to your other devices |
427
+ | `POST /api/sessions/:id/read {open?}` · `GET /api/reads` | per-user read marks, synced to your other devices; `open: true` (the console sends it when you open the session, not for its automatic marks) also counts as touching it |
428
+ | `GET /api/activity/mine` | per-user "touched by me" and the activity meter: `{activity: {sessionID: {at?, kind?, last?, buckets}}}`. `at` (ms) and `kind` (`prompt-tui`, `prompt-web`, `voice`, `answer`, `view`) say what the root session shows (your last action, else your last open), `last` your latest touch of any kind; `buckets` are `[start, yourActions, agentTurns, agentToolCalls]` per 30 minutes of the last 24 h. Changes stream as SSE `{type: "activity", sessionID, activity}`. Kept 30 days, at most 2000 sessions per user. See [docs/session-view.md](docs/session-view.md#finding-your-sessions) |
429
+ | `GET /api/intents/mine` | the stored intents of your sessions, `{intents: {sessionID: {text, source}}}` (never the title fallback): the second line under titles in the session list |
416
430
  | `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). Split panes add `sessions=<id>,<id>` (at most 4 with `session`, else 400): each listed tree streams, each event once, and the stream starts with `{type: "scope", sessions}` naming the ones accepted (your own only); without `sessions` nothing changes. Heartbeat: `event: ping` every 20 s |
417
431
  | `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` |
418
432
  | `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) |
@@ -451,9 +465,20 @@ the console shows it in the session's Details rail, with Edit (live: the console
451
465
  Retention: every 6 h one hub drops the intents of deleted sessions and of sessions neither seen nor written for 30
452
466
  days; an online session's intent is always kept. A dropped intent's revision starts over at 0.
453
467
 
468
+ **Context cards.** Beside the intent, the hub keeps short, automatically updated [context cards](docs/context-cards.md)
469
+ per session, from a registry of providers: `pickup` (primary; a light model the hub calls writes "pick up where you
470
+ left off": a ≤ 90-character headline and a short summary) and `changes` (the working tree, no model). They are **opt-in per
471
+ user** (off until you turn them on in Settings › Concierge; nothing is sent to a model before). Then they run on turn
472
+ end, when a session gets blocked, when you come back to it, on a 30-minute sweep and on Refresh, only for sessions you
473
+ touched in the last 24 h, throttled, skipped when nothing changed and capped per user per day.
474
+ `GET /api/agent/sessions/:addr/context-cards` answers `{sessionID, cards, providers}` (users, `sessions:read`,
475
+ session tokens, like the intent read). New cards are `session.contextCard {provider, version, primary}` events,
476
+ never the text; the Concierge board shows each root's primary headline (`pick up «…»`).
477
+
454
478
  **Events.** `GET /api/agent/events?cursor=&wait=&limit=&type=&session=` reads a bounded per-user journal (24 h or
455
479
  10,000 events) of compact events: `session.added/removed/changed`, `blocker.opened/closed`, `machine.changed`,
456
- `message.changed`, `launch.changed`, `session.archived`, `session.named`, `session.intent`, `role.claimed`, `role.released`. Each is `{id, at, type, subject, data}` with IDs and states
480
+ `message.changed`, `launch.changed`, `session.archived`, `session.named`, `session.intent`, `session.contextCard`, `role.claimed`, `role.released`,
481
+ `subscription.created/removed`. Each is `{id, at, type, subject, data}` with IDs and states
457
482
  only: never prompts, message bodies, titles, blocker summaries, launch reasons or labels. Without a cursor the answer
458
483
  is empty with a cursor at the latest event; pass the returned `cursor` back to get what came after. `wait` (0–60 s)
459
484
  holds the request while nothing matches; `limit` is 1–200 (default 50); `type` (exact, or `launch.*`) and `session`
@@ -518,21 +543,28 @@ cookie and a hub bearer token (400).
518
543
  | `GET /api/agent/status` | ✓ | ✓ | ✓ | machines → projects → sessions, each with name, roles, state, harness, activity and blockers, plus `attention` |
519
544
  | `GET /api/agent/sessions?project=&state=&machine=&label=k=v&q=` | ✓ | ✓ | ✓ | the same sessions as a flat list; `q` (≤ 200 characters): every word must appear in the title or @name (case, `@` and punctuation ignored) |
520
545
  | `GET /api/agent/sessions/:addr` | ✓ | ✓ | ✓ | one session with full blockers and the spoke's summary (`unsupported` when its harness lacks it) |
521
- | `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` |
546
+ | `GET /api/agent/sessions/:addr/log?n=20` | ✓ | ✓ | ✓ (or `conversation:read` for [the app's sessions](docs/app-front.md#conversation-for-apps)) | newest timeline rows (1–200); `422 unsupported {feature, harness}` when the spoke has no timeline, `503 offline` |
522
547
  | `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 |
523
548
  | `GET /api/agent/events?cursor=&wait=&limit=&type=&session=` | ✓ | ✓ | `events:subscribe` (+ each family's scope) | compact events after a resumable cursor (see **Events** above) |
524
549
  | `GET /api/agent/who?project=&role=` | ✓ | ✓ | ✓ | named, role-holding and online sessions |
550
+ | `POST /api/agent/subscriptions {subscriber?, to, types?, delivery?, batch?, maxPerHour?, expires?, note?}` · `GET …/subscriptions?session=` · `GET`/`DELETE …/subscriptions/:id` | ✓ | itself only | `events:subscribe` + `sessions:read` (+ `messages:send` unless `delivery: "silent"`, + family scopes); its own | a session follows other sessions' events as batched hub messages ([docs/subscriptions.md](docs/subscriptions.md)) |
525
551
  | `POST /api/agent/name {name, session?}` | ✓ | own session | `sessions:manage` | names a session (`409 name_taken`) |
526
552
  | `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 |
527
553
  | `POST /api/agent/msg {to, text, wake?, urgent?, all?}`; `Idempotency-Key` | ✓ | ✓ | `messages:send` | tells a session (`all`: every holder of a role); never yourself |
528
554
  | `POST /api/agent/ask {to, text, expiresIn?, wait?}`; `Idempotency-Key` | ✓ | ✓ | `messages:send` | an ask, open until answered, declined or expired; `wait` (≤ 60 s) returns the `answer` |
529
555
  | `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?}` |
530
556
  | `GET /api/agent/decisions?state=` · `…/decisions/:id` | ✓ (all) | own | `messages:read` (own) | the Decisions the caller asked |
557
+ | `GET /api/agent/decisions?answerable=1[&state=]` · `POST /api/agent/decisions/:id/answer {option?, text?}` | – | – | `decisions:answer` | [Decisions from apps](docs/app-front.md#decisions-from-apps): confirm-tier Decisions without an action of a session the app launched or holds a grant allowing `answer` for (others `404`); `410 gone` when not open or another answer won, `403` tap tier / any action / Concierge-asked / a lasting option, `400` with `via`, `423 paused` |
531
558
  | `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 |
532
559
  | `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 |
533
560
  | `POST /api/agent/mute {session}` · `…/unmute` | ✓ | ✓ | `messages:send` | refuses (or allows again) messages from a session |
534
561
  | `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
535
562
  | `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
563
+ | `GET` · `POST /api/agent/app-clients {name, redirectURIs, scopes, audience?}` · `PATCH`/`DELETE …/:id` · `POST …/:id/secret` | ✓ | – | – | registered app clients ([connect an app](docs/app-front.md#connect-an-app)): admins register hub-wide ones, anyone their own (`audience: "user"`); the secret is shown at create and rotate only |
564
+ | `GET /api/agent/connections` · `DELETE …/:id` | ✓ | – | – | your connected apps (scopes, created, last refresh); delete revokes one (its tokens, grants and webhooks) |
565
+ | `POST /api/agent/speech/transcribe` (raw audio) · `POST /api/agent/speech/speak {text}` | – (console: `/api/transcribe`, `/api/speak`) | – | `speech:use` | [speech for apps](docs/app-front.md#speech-for-apps): `{text}` · `audio/mpeg`, with the user's own speech provider and key; per-credential quotas (`429 rate_limited`), `422 unsupported` without a provider, `502 upstream_failed`; nothing recorded |
566
+ | `GET /api/agent/sessions/:addr/stream` (SSE; `Last-Event-ID`) | ✓ | – | `conversation:read` | [conversation for apps](docs/app-front.md#conversation-for-apps): `ready`, `delta`, `say`, `heard`, `status`, `conversation`, `reset`, `end` of a session the app launched or holds a grant allowing `prompt` for (others `404`); 4 streams per app, 20 per user (`429`) |
567
+ | `POST /api/agent/sessions/:addr/conversation {on, front?}` · `…/utterance {text}` · `…/interrupt` | ✓ | – | `conversation:write` + a grant allowing `prompt` | conversation mode (the voice front with `front: true`) · `202 {inboxID}` (counts against `maxActionsPerHour`, marked as the app's prompt) · `204`; kill switch `423 paused` |
536
568
 
537
569
  Sends to a session (`msg`, `ask`, `reply`, `decline`) answer `{message, delivery, woke, wakeSkipped?}`: `woke` says
538
570
  whether the hub sent it to wake the recipient, and when not, `wakeSkipped` says why (`wake_false`, `subagent`,
@@ -567,7 +599,26 @@ decision}` on every open, answer, timeout, escalation and expiry); a `decision`
567
599
  Reads come from the hub's catalog; only `show` (summary) and `log` (timeline) ask the session's spoke, so they
568
600
  depend on what its harness supports. A session token reads all of its user's sessions but names and claims roles
569
601
  only for itself; an app token reads, and messages only with the `messages:*` scopes. `GET /.well-known/hub` (public) answers `{publicURL, version,
570
- minClientVersion, auth, features}` for apps and CLIs.
602
+ minClientVersion, auth, features}` for apps and CLIs; `features` lists `app-connect` (apps connect with consent:
603
+ [docs/app-front.md](docs/app-front.md#connect-an-app)), `app-speech` (speech for apps:
604
+ [docs/app-front.md](docs/app-front.md#speech-for-apps)), `app-conversation`
605
+ ([conversation for apps](docs/app-front.md#conversation-for-apps)) and `app-decisions`
606
+ ([Decisions from apps](docs/app-front.md#decisions-from-apps)).
607
+
608
+ **Connected apps.** A web app's server connects to a user's account with an OAuth 2.1-style consent flow instead of a
609
+ hand-made token: `GET /connect/authorize` (PKCE S256) → the hub's consent page, where the user can narrow the scopes →
610
+ `POST /connect/token` → a 1-hour `hca_` access token and a rotating `hcr_` refresh token; `POST /connect/revoke` ends
611
+ it. Clients are registered in Settings → Agents & access. See [docs/app-front.md](docs/app-front.md#connect-an-app).
612
+
613
+ **Hub as an app's AI layer (app front).** A web app can use Hub as its AI layer for its users: connect (above), launch
614
+ helper sessions, follow and talk to them (a conversation stream, voice turns through the voice front), speak and
615
+ transcribe with the user's own speech provider, and answer the everyday confirm-tier Decisions its own sessions ask.
616
+ Always through the app's **own server** (a BFF): hub tokens never reach a browser, and the hub sends no CORS headers.
617
+ The guide: [docs/app-front.md](docs/app-front.md) (the BFF pattern, scopes, the grants each feature needs, limits,
618
+ the refresh rule, security rules). The SDK (`packages/sdk`: `HubConnect`, `ConnectionTokens`, `hub.speech`,
619
+ `hub.conversation`, `hub.appDecisions`) and the voice loop's `appTransport` (`packages/voice-client`) do the work; a
620
+ reference BFF lives in [examples/app-front-bff](examples/app-front-bff/README.md), and the
621
+ [app fixture](docs/app-integration-testing.md) runs a real hub to test an app against.
571
622
 
572
623
  **Apps.** A native app links with a code from Settings → Devices → **Add app** (`POST /api/devices/link`, then
573
624
  `POST /api/devices/redeem` from the app; see [Multi-user](#multi-user-sign-in-with-google)) and uses the device
@@ -592,7 +643,7 @@ you. Addresses resolve among your own sessions only; another user's sessions do
592
643
  | `reply <msgID> "text"` · `decline <msgID> --why "…"` | answers a message you received; it goes to the message's real sender, never through the role |
593
644
  | `inbox` | asks waiting for your answer, your own open asks, recent messages, mutes |
594
645
  | `mute <addr>` · `unmute <addr>` | refuses (or accepts again) messages from a session (`forbidden`, exit 5) |
595
- | `who` | named and role-holding sessions |
646
+ | `who` | online sessions, and named or role-holding ones while offline |
596
647
 
597
648
  `--no-wake` admits a message without starting a turn and `--urgent` steers it into a running turn. Texts are at
598
649
  most 10,000 characters (a decline's reason 1,000).
@@ -643,7 +694,8 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
643
694
  **Where agents send from.**
644
695
  - In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
645
696
  - The model itself: the plugin's `hub` tool (verbs `msg`, `ask`, `reply`, `decline`, `inbox`, `who`, `status`,
646
- `whoami`, the [team intake](docs/teams-intake.md) verbs `intake` and `intake_update`, `name`, `role_claim` and
697
+ `whoami`, the [team intake](docs/teams-intake.md) verbs `intake` and `intake_update`, the
698
+ [subscription](docs/subscriptions.md) verbs `subscribe`, `unsubscribe` and `subscriptions`, `name`, `role_claim` and
647
699
  `role_release` for its own @name and roles, and the [act verbs](#agent-actions-and-grants)), as the calling session ([Spoke](#spoke-opencode-plugin)).
648
700
  - You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
649
701
  - Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with, or the hub's
@@ -792,7 +844,7 @@ and the SDK. Only you manage grants: the console (Settings → Agents) or the CL
792
844
  | `POST /api/agent/sessions/:addr/approve {requestID, always?, why?}` | ✓ | under a grant | under a grant |
793
845
  | `POST /api/agent/sessions/:addr/deny {requestID, why?}` | ✓ | under a grant | under a grant |
794
846
  | `POST /api/agent/sessions/:addr/answer {formID, answer \| cancel: true, why?}` | ✓ | under a grant | under a grant |
795
- | `POST /api/agent/sessions/:addr/prompt {text, delivery?, why?}` (users: `images?`) | ✓ | under a grant | under a grant |
847
+ | `POST /api/agent/sessions/:addr/prompt {text, delivery?, why?}` (users: `images?`) | ✓ | under a grant | under a grant (or `conversation:write`, the app's own sessions) |
796
848
  | `POST /api/agent/sessions/:addr/stop {why?}` | ✓ | under a grant | under a grant |
797
849
  | `POST /api/agent/grants` · `GET` · `GET`/`PUT`/`DELETE …/grants/:id` | ✓ | – | – |
798
850
  | `POST /api/agent/grants/requests {grant, why}` | – | ✓ (any token) | `messages:send` |
@@ -867,7 +919,7 @@ hub show <addr> state, blockers, activity, summary
867
919
  hub log <addr> [-n 20] newest timeline rows (1–200), printed oldest first
868
920
  hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pings skipped)
869
921
  hub whoami who this credential is
870
- hub who [--project P] [--role R | @@role] named and role-holding sessions
922
+ hub who [--project P] [--role R | @@role] online sessions, and named or role-holding ones while offline
871
923
  hub name <@name> [--session <addr>]
872
924
  hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters)
873
925
  hub role claim <role[.project]> [--exclusive] [--session <addr>]
@@ -881,6 +933,9 @@ hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…
881
933
  hub decisions list [--state open|answered|expired|closed] the Decisions you asked
882
934
  hub inbox asks to answer, your open asks, recent messages, mutes
883
935
  hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
936
+ hub subscribe <addr|@@role|#team|children>... [--type T]... [--delivery queue|steer|silent] [--batch 30s]
937
+ [--expires 1d] [--max-per-hour N] [--subscriber <addr>] batched hub messages about their events
938
+ hub subscriptions [--session <addr>] · hub unsubscribe <subID>
884
939
  hub approve <addr> <requestID> [--always] [--why "…"] answer a permission request (once, or always)
885
940
  hub deny <addr> <requestID> [--why "…"] reject it; the reason reaches the harness
886
941
  hub answer <addr> <formID> key=value… | --json '{…}' | --json - | --cancel [--why "…"]
@@ -1113,7 +1168,7 @@ event stream comes back) it asks its own server what runs now (`session.active`
1113
1168
 
1114
1169
  | Entry | File | Role |
1115
1170
  |---|---|---|
1116
- | 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 |
1171
+ | server | `server.ts` → `src/index.ts` | Starts the process-wide spoke (`src/spoke/`), `hub.*` RPC (also under the legacy id `voice.*`), the hidden `voice-front` and `concierge` agents, and the `context` hook (short spoken replies, the voice front's note and tools) |
1117
1172
  | tui | `tui.ts` → `src/tui.ts` | `/hub` dialog and the feature commands (`/voice`), registered through `src/tui-commands.ts` (ids `hub.<id>`, palette group "Hub"); reports the sessions it shows (`hub.presence`) |
1118
1173
  | rpc | `rpc.ts` → `src/rpc.ts` | `HubRpc` (id `hub`; `VoiceRpc` is the deprecated id `voice`): `status / setServer / checkServer / enroll / startLocalHub / setMode / setSession / pairCode / handoff / openLink / conversation / presence` |
1119
1174
 
@@ -1122,10 +1177,12 @@ The plugin ids are `hub` (server) and `hub.tui`. OpenCode resolves a local plugi
1122
1177
 
1123
1178
  - **Config**: `~/.config/hub/config.json` (or `$HUB_SPOKE_CONFIG`), shared by all
1124
1179
  processes on the machine, mode 0600, watched live. Holds the server URL, mode, machine token,
1125
- `subagent` (the agent conversation mode delegates to; default `general`, `[A-Za-z0-9_.-]+`),
1180
+ `voiceModel` (the voice front's model, `provider/model[#variant]`; pick a fast paid one),
1126
1181
  `autoStartLocalHub` (default `true`; see [Local only](#local-only)), `herdr` (default `true`; read open
1127
1182
  sessions and workspaces from [Herdr](#open-sessions-herdr-and-tuis)), and `list` (the history window,
1128
1183
  default `{"days": 30, "max": 100}`; days 1–365, max 1–500; out-of-range values fall back per field).
1184
+ `subagent` and `workModel` (the removed voice agent's delegate agent and worker model) are obsolete: read
1185
+ without error, ignored, logged once per process, and dropped the next time the plugin saves the file.
1129
1186
  - **Modes**: `off` (no connection), `always` (every session this process knows, Voice on for all),
1130
1187
  `session` (Voice only for sessions switched on in `/hub` or from the phone, plus their subagent
1131
1188
  sessions). Every reported session can be inspected and controlled in both modes; in `session` mode the
@@ -1194,20 +1251,34 @@ Sessions open in a terminal are reported with `open: true` and `terminal` (`src/
1194
1251
 
1195
1252
  ## Conversation mode (OpenCode side)
1196
1253
 
1197
- In conversation mode the session's main agent becomes a voice orchestrator: it answers in one or two
1198
- spoken sentences, handles quick answers and up to two low-risk tool calls directly, and hands multi-step
1199
- work to a background subagent. Routine workers inherit the fast voice model; coding, debugging,
1200
- architecture and risky work use the configured full work model. Successful workers finish with a short
1201
- speech-ready result that the hub reads directly, without waiting for another voice-agent summary. You
1202
- can keep talking while work runs; related follow-ups continue the active worker and unrelated tasks start
1203
- a fresh one. Several independent requests in one breath ("check the deploy and update the changelog") start
1204
- one background worker each, in parallel, in the same turn; each worker gets a short name ("Deploy check") that
1205
- the task board and announcements use.
1206
-
1207
- The phone composer exposes separate **Voice** and **Worker** model controls for each session. Each picker
1208
- lists the connected OpenCode process's available models and variants. Choices are saved per user and
1209
- session, apply to the next turn or newly started worker, and fall back to the machine's `voiceModel` and
1210
- `workModel` when unset.
1254
+ Voice mode (conversation mode) always uses the **voice front** when it can: a hidden, hub-managed OpenCode session
1255
+ on the fast `voiceModel`, one per conversation (design: `docs/plans/2026-11-06-voice-front-design.md`, contract:
1256
+ `docs/plans/2026-11-06-voice-front-p1-contract.md`). Every reply starts with a short echo of what it understood;
1257
+ it relays your request to the session (a new turn when the session is idle, steering when it is busy, queued for
1258
+ an unrelated request) or stops it, and speaks the session's results, questions and failures. It has no shell, no
1259
+ edits and no subagents. The session you talk to keeps its own agent, model, tools and subagents.
1260
+
1261
+ The front's model requests are kept short so its echo starts at once (`src/spoke/voice-front.ts`, applied by the
1262
+ `context` hook to linked fronts only):
1263
+ - **No thinking:** an Anthropic-API model (also through a gateway such as OmniRoute) gets `thinking: {type:
1264
+ "disabled"}`, overriding a model's own thinking setting or a `#variant`. An OpenAI-style model gets the lowest
1265
+ reasoning effort it lists (`none`, else `minimal`); one that lists neither gets nothing, because a non-reasoning
1266
+ model may reject `reasoning_effort`. Other providers keep their default.
1267
+ - **Lean system prompt:** only the front's own prompt. OpenCode's instruction files (`AGENTS.md`, `CLAUDE.md`,
1268
+ `instructions`), model, date, environment and worktree blocks are left out; the front gets its two tools, the
1269
+ per-turn note and its conversation.
1270
+
1271
+ Without a front the hub talks to the session directly and the session answers in short spoken replies (the
1272
+ `context` hook adds a voice style to its turns): when the device turned **Voice front** off (Settings → Voice), when
1273
+ the machine's plugin is older or cannot create the front, after the front failed (`front_failed`, e.g. a free model
1274
+ out of quota), and when an older hub sends `conversation {on}` without `front`. In no case is the session's agent or
1275
+ model switched. Earlier versions switched the session to a `voice` agent on the fast model that delegated work to
1276
+ background subagents; that agent, its orchestrator prompt, the `hub_task_stop` tool and the `subagent` /
1277
+ `workModel` settings are gone.
1278
+
1279
+ The phone composer has one **Voice** model control per session: the voice front's model. The picker lists the
1280
+ connected OpenCode process's models and variants; the choice is saved per user and session, applies the next
1281
+ time Voice mode starts, and falls back to the machine's `voiceModel` when unset. Pick a fast, paid model.
1211
1282
 
1212
1283
  The conversation is an inline execution timeline: streamed thinking, tools and nested subagents appear
1213
1284
  between messages as compact expandable rows. Rows keep the session's order, never wall-clock order: history
@@ -1220,26 +1291,19 @@ not persist those potentially sensitive details.
1220
1291
 
1221
1292
  **Spoke side** (`src/spoke/conversation.ts`, `src/spoke/spoke.ts`):
1222
1293
 
1223
- - The plugin registers a primary agent **`voice`** through `ctx.agent.transform` (the agent editor's
1224
- `update()` creates missing agents). Its system prompt is the orchestrator prompt; it inherits the
1225
- default `build` agent's permissions so your rules still apply. A `voice` agent you define yourself
1226
- (e.g. `~/.config/opencode/agents/voice.md`) takes precedence and is left untouched.
1227
- - `conversation on` remembers the session's current agent and switches it to `voice`; `off` switches
1228
- back (or to the default agent), unless you picked another agent in the meantime. If no `voice`
1229
- agent exists, the session keeps its agent and the `context` hook adds the orchestrator prompt instead.
1230
- - The delegate agent (`subagent` in the config) is added per request by the `context` hook, so changing
1231
- it needs no restart.
1232
- - **Task board**: the `context` hook also gives the voice agent (and a conversation on another agent) a live
1233
- list of the workers it started (`src/spoke/taskboard.ts`, from this process's events): title, sessionID,
1234
- state (working, blocked with what it waits for, done with its latest text, failed with the reason, stopped)
1235
- and for how long, at most 8 (live ones first) of at most 200 characters each. So "what's running?" or "how's
1236
- the deploy going?" needs no tool call. A session without workers gets no board.
1237
- - **Stopping one task by voice**: the plugin adds a tool `hub_task_stop {sessionID} | {all: true}`. The voice
1238
- agent calls it for "cancel the deploy one" (that worker and whatever runs below it are interrupted, deepest
1239
- first) or "stop all the workers" (every running worker of the session). It only reaches sessions below the
1240
- calling session (never the caller itself, its siblings or another session's workers), so the voice agent
1241
- keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
1242
- interrupt the root turn at once.
1294
+ - The plugin registers the hidden primary agent **`voice-front`** through `ctx.agent.transform` (everything denied
1295
+ but its two relay tools, `voice_main_send` and `voice_main_stop`, which the `context` hook offers only to a linked
1296
+ front). It registers no `voice` agent: a session an older plugin left on one (a crash mid-conversation) is moved
1297
+ to the default agent before the hub's next prompt, or after a turn fails with "Agent not found", because OpenCode
1298
+ 2.2.3 fails every turn on a missing agent. A `voice` agent you define yourself is left alone.
1299
+ - `conversation on` with `front` creates (or re-links) the front; `on` without it, or when no front can be made,
1300
+ marks the session's turns as voice turns. `off` deletes the front. Both answer with the session's current agent
1301
+ and never switch it.
1302
+ - **Task board**: the front's per-turn note about its session lists the session's workers (`src/spoke/taskboard.ts`,
1303
+ from this process's events): title, state (working, blocked with what it waits for, done with its latest text,
1304
+ failed with the reason, stopped) and for how long, at most 8 (live ones first) of at most 200 characters each.
1305
+ So "what's running?" or "how's the deploy going?" needs no tool call. The phone's stop words ("stop", "cancel",
1306
+ "never mind", "stop that", "hold on") still interrupt the turn at once.
1243
1307
  - **Messaging other sessions**: the plugin adds a tool `hub {verb, …}` with the verbs `msg`, `ask`, `reply`,
1244
1308
  `decline`, `inbox`, `who`, `status`, `whoami`, `intake` and `intake_update` (`src/spoke/hub-tool.ts`). It acts as the calling session
1245
1309
  with that session's own hub token (the token never appears in the tool's output). `name {name}` sets the
@@ -1251,8 +1315,9 @@ not persist those potentially sensitive details.
1251
1315
  - Child text and execution events are paired by the hub. The latest assistant message of a successful
1252
1316
  child is emitted as root-session speech; interruptions and stale partial results are not. **Announcements**
1253
1317
  (hub, `src/central/registry.ts`): while another worker of the session runs or waits, or one was announced in
1254
- the last minute, a result starts with the task's title ("Deploy check finished: …"); a result the voice agent
1255
- already said in the last two minutes is not read again. A failed worker is announced once with its reason
1318
+ the last minute, a result starts with the task's title ("Deploy check finished: …"); a result the session
1319
+ already said in the last two minutes is not read again (with a voice front, worker results are the front's to
1320
+ report and the hub does not read them). A failed worker is announced once with its reason
1256
1321
  ("Lint failed: rate limit exceeded."), a worker's permission request or question once per request ("Cleanup
1257
1322
  needs your permission.", "… has a question."); a stopped worker is not. Announcements are ordinary `say`s
1258
1323
  without `seq` (`messageID` `announce:failed:<session>`, `announce:permission:<id>`, `announce:form:<id>`),
@@ -1260,13 +1325,13 @@ not persist those potentially sensitive details.
1260
1325
  - Server ops: `steer` sends the text with `delivery: "steer"` (metadata `{source: "hub", mode:
1261
1326
  "conversation"}`, or `mode: "remote"` for a session not shared with Voice; 0.5 wrote `source:
1262
1327
  "opencode-hub"` and 0.4 and earlier `source: "opencode-voice"`, and readers accept all three) and returns the inbox ID at once; `interrupt`
1263
- interrupts the session; `conversation` turns the mode on/off and returns the agent now in effect, and
1264
- is refused for sessions not shared with Voice. Replies reach the hub as forwarded
1328
+ interrupts the session; `conversation` turns the mode on/off and returns the session's agent (never
1329
+ switched), and is refused for sessions not shared with Voice. Replies reach the hub as forwarded
1265
1330
  `session.text.ended`, `session.inbox.delivered` and `session.agent.selected` events.
1266
1331
  - State is per process and in memory; the hub re-sends `conversation on` after a reconnect.
1267
- - From the terminal: `/voice` opens the current session in the browser with Voice mode starting; `/hub →
1268
- Conversation mode` toggles conversation mode only (same code path as the phone), then offers to send the
1269
- session to your phone.
1332
+ - From the terminal: `/voice` opens the current session in the browser with Voice mode starting (with the voice
1333
+ front, unless that browser turned it off); `/hub → Conversation mode` only toggles short spoken replies for the
1334
+ session in this process (no front), then offers to send the session to your phone.
1270
1335
 
1271
1336
  ## Central server
1272
1337
 
@@ -1293,8 +1358,10 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
1293
1358
  | `HUB_RELEASE_BASE` | `https://github.com/huynle/hub-releases/releases/download/v<version>` | where `/install.sh` and `/install.ps1` download hubctl ([Set up a machine](#set-up-a-machine-one-line)): https, or plain http on loopback or a private-network IP address; the hub refuses to start with any other value. Without it a development version (not a plain `x.y.z`) answers 503 |
1294
1359
  | `HUB_CSP` | `enforce` | `report-only` sends the console's Content-Security-Policy as `Content-Security-Policy-Report-Only` (violations are only logged in the browser console); see [Content-Security-Policy](#content-security-policy) |
1295
1360
  | `HUB_REMOTE_FETCH` | `on`; `off` when `HTTP_PROXY`/`HTTPS_PROXY` (either case) is set and `NO_PROXY` is not `*` | `on` or `off`. A proxy could re-resolve hostnames and undo the fetcher's address pinning, so with a proxy in the environment remote fetch stays off (with a startup warning) unless you set `NO_PROXY=*` for the hub process (direct fetches) or `HUB_REMOTE_FETCH=on` (accept proxying knowingly). `off` turns off the hub fetching web images and link previews for the console (`GET /api/media/remote` and `GET /api/media/unfurl` answer `403 disabled`). Signed-in users only (session and app tokens are refused); http/https on default ports, private and reserved addresses refused, 8 MiB, never decompressed (compressed responses are refused), PNG/JPEG/WebP/GIF only, 30 a minute and 4 at once per user (images and link previews together); the audit log keeps the host, never the URL. Any other value stops the hub at start. Threat model and residual risks: [docs/media-security.md](docs/media-security.md) |
1361
+ | `HUB_TRUSTED_PROXIES` | | comma list of reverse-proxy addresses or CIDRs whose `X-Forwarded-For` names the client for per-address rate limits ([connect an app](docs/app-front.md#connect-an-app)); unset: the TCP peer is the client and `X-Forwarded-For` is never read; an invalid entry stops the hub at start |
1296
1362
  | `HUB_FETCH_DENY_CIDRS` | | comma list of extra IPv4 or IPv6 CIDRs the hub never fetches from (`GET /api/media/remote`, `GET /api/media/unfurl`), on top of the built-in loopback, private, link-local (cloud metadata), CGNAT, multicast, reserved, ULA and mapped ranges (e.g. your Kubernetes pod and service CIDRs: `10.42.0.0/16,10.43.0.0/16`); an invalid entry stops the hub at start |
1297
- | `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
1363
+ | `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers); [context cards](docs/context-cards.md) on OpenRouter reuse it |
1364
+ | `HUB_CONTEXT_CARDS` | on | `off` (or `false`, `0`, `no`) turns [context cards](docs/context-cards.md) off for every user (the kill switch over `hub.json` `contextCards.enabled`); `on` (`true`, `1`, `yes`) overrides it. Any other value stops startup |
1298
1365
  | `HUB_STT_MODEL`, `HUB_TTS_MODEL`, `HUB_VOICE`, `HUB_MAX_*` | see `src/central/config.ts` | speech defaults; the phone's speech settings override them |
1299
1366
 
1300
1367
  The Docker image installs only the hub's runtime packages (web-push and its dependencies, pinned by
@@ -1344,8 +1411,10 @@ recent activity.
1344
1411
  `web/` is the Hub console, a Solid + Vite + TypeScript PWA built to `web/dist` (`npm run build:web`;
1345
1412
  the Docker image, npm package and binaries ship the build). One store fed by the hub's SSE stream drives:
1346
1413
 
1347
- - **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
1348
- terminal grouped by project (or server) with `+`, Recents. Each section collapses from its heading (remembered on
1414
+ - **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, **Working set** (your pinned
1415
+ sessions and the 7 you touched most recently within 7 days; Pinned when you touched none), Running, sessions open
1416
+ in a terminal grouped by project (or server) with `+`, Recents. Rows show the session's intent as a muted second
1417
+ line, and Needs you rows how long they have waited. Each section collapses from its heading (remembered on
1349
1418
  the device, `hub.sidebar.sections.collapsed`), showing its count; Needs you always shows its count. Rows show status, a pulsing green dot left of the
1350
1419
  title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
1351
1420
  (rename, pin, archive, stop, Allow/Disallow Voice).
@@ -1400,7 +1469,7 @@ the Docker image, npm package and binaries ship the build). One store fed by the
1400
1469
  recording cancels it; each session keeps its own unsent draft), Voice mode (hands-free, see below; a toggle that
1401
1470
  turns Voice on first when needed; one session at a time, so turning it on elsewhere turns the first one off; it
1402
1471
  keeps running while you look at other sessions, which show a "Voice: <session>" pill with End) with a
1403
- microphone picker, and the Voice/Worker model menus. The right rail (a bottom sheet on phones) has
1472
+ microphone picker, and the Voice model menu (the voice front's model). The right rail (a bottom sheet on phones) has
1404
1473
  cards that collapse from their header (remembered per device): **Environment** (machine with
1405
1474
  online/offline/Connecting, project folder with the full path as tooltip, branch, and where it is open:
1406
1475
  TUI, Herdr or background), Context, **Connectors · N connected** (MCP servers) and Changes with a diff
@@ -1685,7 +1754,7 @@ public URL, `localhost`, `127.0.0.1` and `[::1]` with the same scheme and port c
1685
1754
  |---|---|---|
1686
1755
  | `POST /api/sessions/:id/utterance {text}` | `steer` | `202 {inboxID}`; allowed while the session is busy (no turn lock). 400 empty or over 4000 chars, 404 no machine of yours reports the session, 502 spoke error, 504 no answer in 15 s |
1687
1756
  | `POST /api/sessions/:id/interrupt` | `interrupt` | `204` |
1688
- | `POST /api/sessions/:id/conversation {on}` | `conversation` | `200 {on, agent}`. 404 when no machine of yours reports the session |
1757
+ | `POST /api/sessions/:id/conversation {on, front?}` | `conversation` | `200 {on, agent, front?}`. `on: true` asks for the voice front unless `front: false` (the device opted out); the front is used when the session's machine serves it (`voiceFront`), and `front` in the answer says whether one answers. `agent` is the session's own agent (never switched). 404 when no machine of yours reports the session |
1689
1758
  | `GET /api/sessions/:id` | | `{id, title, directory, parentID?, project?, projectName?, updated, exposed, state, blockers, now?, busy, conversation, status, activity}`; catalog sessions also carry `conversation` |
1690
1759
  | `POST /api/sessions/:id/presence {visible}` | | `204`; the phone posts on `visibilitychange` and every 30 s while visible. A root session is *watched* while any device reported it (or one of its subagent sessions) visible within 45 s |
1691
1760
  | `POST /api/sessions/:id/permission {requestID, decision}` | `permission` | `204`. `decision` is `once`, `always` or `reject`; `:id` is the session or any ancestor. Tried on every process that forwarded the request (OpenCode keeps pending permissions per process and per location; the spoke replies through the plugin instance of the location that holds it). `409 {code: "gone"}` "This request was already answered or expired" when none holds it (its card is dropped). 400 bad decision |
@@ -1779,10 +1848,13 @@ around the mic with the current state: Listening, Hearing you, Transcribing, Sen
1779
1848
  it again, say "pause listening", or leave the page to turn it off. Leaving the page calls
1780
1849
  `{on:false}` with `keepalive`. While conversation mode is off, tap-to-talk works as before.
1781
1850
 
1782
- - **Speech detection** (`web/src/core/speech-vad.ts`; ~20 ms frames from `web/public/vad-worklet.js`, with a
1851
+ The loop is the package `packages/voice-client` (a `VoiceTransport` over the console's routes and SSE,
1852
+ `web/src/voice/console-transport.ts`); other web apps can embed it ([docs/app-front.md](docs/app-front.md#voice-in-an-app-packagesvoice-client)).
1853
+
1854
+ - **Speech detection** (`packages/voice-client/src/core/speech-vad.ts`; ~20 ms frames from `web/public/vad-worklet.js`, with a
1783
1855
  ScriptProcessor fallback): on-device [Silero VAD](https://github.com/snakers4/silero-vad) v5 (MIT) through
1784
1856
  onnxruntime-web (MIT, wasm, single-threaded, so no COOP/COEP headers are needed) in a Web Worker
1785
- (`web/src/voice/silero-worker.ts`). The mic audio is resampled to 16 kHz and cut into 32 ms chunks; each
1857
+ (`packages/voice-client/src/silero-worker.ts`). The mic audio is resampled to 16 kHz and cut into 32 ms chunks; each
1786
1858
  chunk's speech probability starts speech at ≥ 0.6 sustained for 250 ms (most of the onset voiced, so
1787
1859
  isolated clicks never add up) and ends it after 350 ms below 0.35. Typing, a fan, clatter, beeps and hum
1788
1860
  score low; speech scores high at any loudness. An energy detector stays as a cheap pre-gate (chunks within
@@ -1798,7 +1870,7 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1798
1870
  - **Echo guard**: while a reply or a progress cue plays, and 400 ms after, a barge-in needs more: the model's
1799
1871
  probability ≥ 0.85 (Low 0.92, High 0.75) and 8 dB more energy. The browser's echo cancellation removes most
1800
1872
  of the reply from the mic first.
1801
- - **Noise transcripts** (`web/src/core/junk-transcript.ts`): what speech-to-text makes of noise is dropped
1873
+ - **Noise transcripts** (`packages/voice-client/src/core/junk-transcript.ts`): what speech-to-text makes of noise is dropped
1802
1874
  before it reaches the agent and shown dim in the Voice bar for 3 s ("ignored: “Thank you.”"): empty or
1803
1875
  punctuation-only text, sound tags ("[Music]", "(applause)", "♪"), Whisper's stock phrases ("Thank you.",
1804
1876
  "you", "Bye.", "Thanks for watching!", "Subtitles by …", "Amara.org" …), one word repeated, and one or two
@@ -1814,10 +1886,10 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1814
1886
  - **Turn detection (experimental, Settings → Voice → Turn detection, per device)**: "Timers (current)" is the
1815
1887
  default and works as above. "Smart Turn (experimental)" decides the end of a turn from the audio: on-device
1816
1888
  [Pipecat Smart Turn v3](https://huggingface.co/pipecat-ai/smart-turn-v3) (v3.2 int8, BSD-2-Clause, 8.7 MB, loaded on
1817
- first use as a hashed asset; license in `web/src/voice/turn/LICENSE`) in its own Web Worker
1818
- (`web/src/voice/smart-turn-worker.ts`, onnxruntime-web as for Silero, so the console policy needs nothing new). The
1889
+ first use as a hashed asset; license in `packages/voice-client/src/turn/LICENSE`) in its own Web Worker
1890
+ (`packages/voice-client/src/smart-turn-worker.ts`, onnxruntime-web as for Silero, so the console policy needs nothing new). The
1819
1891
  console keeps the last 8 s of the turn's 16 kHz mic audio, pauses included (from the turn's first speech start),
1820
- computes Whisper's log-mel features in TS (`web/src/core/smart-turn-features.ts`, matched to transformers'
1892
+ computes Whisper's log-mel features in TS (`packages/voice-client/src/core/smart-turn-features.ts`, matched to transformers'
1821
1893
  `WhisperFeatureExtractor` within 1e-5) and scores it at each segment end: a score ≥ 0.5 sends 150 ms after the
1822
1894
  score once the transcripts are in; a lower one holds and is re-scored every 400 ms of quiet, with a fallback after
1823
1895
  3 s. Speech resuming cancels the send as with the timers; commands, the 60 s cap and noise transcripts are
@@ -1837,7 +1909,7 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1837
1909
  list items are handled, and fenced code becomes "(code omitted)". Each sentence goes to `/api/speak`,
1838
1910
  up to two ahead of playback, and plays through one `<audio>` element while its MP3 is still downloading:
1839
1911
  through MediaSource (`audio/mpeg` SourceBuffer; Chrome, Android, Firefox), appending bytes as they arrive
1840
- (`web/src/core/audio-stream.ts`). Browsers without MP3 in MediaSource (some iOS Safari versions) play each
1912
+ (`packages/voice-client/src/core/audio-stream.ts`). Browsers without MP3 in MediaSource (some iOS Safari versions) play each
1841
1913
  sentence once it has fully downloaded, as before. Prefetched sentences keep downloading while they wait. Speech while a reply plays
1842
1914
  (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
1843
1915
  being written whose next sentence has not arrived yet (their later sentences still show in the transcript); a
@@ -1876,7 +1948,7 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1876
1948
 
1877
1949
  ### Voice feedback (phone)
1878
1950
 
1879
- Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/core/cues.ts` plays cues.
1951
+ Pure logic is in `packages/voice-client/src/core/feedback-core.ts` (tested with bun); `packages/voice-client/src/core/cues.ts` plays cues.
1880
1952
 
1881
1953
  - **Voice speed**: 0.75×–2.0× in 0.05 steps, next to "Read replies aloud" and in Settings → Voice
1882
1954
  (default 1.0×, stored per device in `localStorage`). It is applied on the phone as the audio
@@ -1896,7 +1968,8 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1896
1968
  newest are at the bottom, and it keeps about 30. The agent's reply streams into its bubble (one bubble per
1897
1969
  assistant message); in conversation mode the spoken `say` text is merged into the bubble of the same message.
1898
1970
  The home list shows the same "now" text with a spinner for working sessions.
1899
- - **Progress cues** (conversation mode; Settings → Voice → "Progress cues", on by default): a soft
1971
+ - **Progress cues** (conversation mode; Settings → Voice → "Progress cues", on by default; with the voice front
1972
+ only the tones play, its echo replaces the spoken phrases below): a soft
1900
1973
  tone as soon as you stop speaking (the moment the segment goes to transcription; typed text gets it when
1901
1974
  the agent receives it), then a short acknowledgement ("On it.", "One moment.", "Let me check." …) if the
1902
1975
  session is working and the agent has not spoken within 2.5 s of the end of your speech. A hand-off phrase
@@ -1905,7 +1978,7 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1905
1978
  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
1906
1979
  play while you are talking or the agent's speech is playing; they wait, and are dropped once the agent
1907
1980
  speaks. Cues are event-driven: one timer for the next deadline (no polling), re-armed on speech, status and
1908
- playback changes. Each cue has several variants (`CUE_VARIANTS` in `web/src/core/feedback-core.ts`), chosen
1981
+ playback changes. Each cue has several variants (`CUE_VARIANTS` in `packages/voice-client/src/core/feedback-core.ts`), chosen
1909
1982
  from a shuffle-bag: every variant once before any repeats, never the same phrase twice in a row, and a variant
1910
1983
  whose audio is already loaded wins. All variants are synthesized, 2 at a time, when Voice mode starts (ending Voice mode
1911
1984
  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
@@ -1921,7 +1994,7 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1921
1994
  (`localStorage` `voice.replyLoudness`, clamped to −26…−14 LUFS) becomes the target. Audio that cannot be decoded
1922
1995
  plays as synthesized. Cues and replies keep their plain `<audio>` elements (no Web Audio playback graph, which
1923
1996
  would break background and iOS playback), both at volume 1 and the chosen speed. Pure parts:
1924
- `web/src/core/loudness.ts`; browser decoding: `web/src/voice/cue-loudness.ts`.
1997
+ `packages/voice-client/src/core/loudness.ts`; browser decoding: `packages/voice-client/src/cue-loudness.ts`.
1925
1998
  - **Presence**: while a session view is open, the phone posts `POST /api/sessions/:id/presence
1926
1999
  {visible}` on visibility changes and every 30 s while visible, and `{visible:false}` on `pagehide` and
1927
2000
  when leaving the view. The hub uses this to decide when to push.
@@ -1946,7 +2019,7 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1946
2019
  device cookie. It closes when Voice mode ends or the page goes away. It needs notification permission:
1947
2020
  without it the Voice bar shows **Allow notifications**. The Media Session shows "Voice mode on" with the
1948
2021
  session title on the lock screen while Voice audio plays; its pause and stop end Voice mode (logic in
1949
- `web/src/core/voice-notifier.ts`).
2022
+ `packages/voice-client/src/core/voice-notifier.ts`).
1950
2023
  - **Settings → Speech** (your own settings; admins also edit the server default): cards for Speech-to-text and Text-to-speech, with provider
1951
2024
  (OpenRouter / OpenAI-compatible), base URL (OpenAI-compatible only), model, voice (TTS), language (STT,
1952
2025
  optional), server-side speed (TTS, optional, 0.25–4) and API key. Model and voice are buttons that open a
@@ -1995,6 +2068,9 @@ in the file).
1995
2068
  | `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) |
1996
2069
  | `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60) and `lostAfterMinutes?` (0–1440 or false, default 5: a running launch the same node instance stops reporting, with its session gone, ends lost; 0/false: never); see [docs/launches.md](docs/launches.md#placement-and-caps) |
1997
2070
  | `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) |
2071
+ | `contextCards` | `{enabled?, model?: {provider?, model?, baseURL?, apiKey?}, budget?: {calls?, tokens?}}`: the light model the hub calls for [context cards](docs/context-cards.md#configuration) (default OpenRouter `anthropic/claude-haiku-4.5` with the speech OpenRouter key; 200 calls and 400,000 tokens per user per day) |
2072
+ | `agents.speechQuotas` | per-credential quotas of [speech for apps](docs/app-front.md#speech-for-apps), any subset of `{transcribe: {perMinute, perHour}, speak: {perMinute, perHour, charsPerHour}}` (defaults 20, 300; 60, 1000, 100000), whole numbers from 1 |
2073
+ | `agents.webhooks.allowPrivateHosts` | exact hostnames (at most 50) whose `https://` webhook URLs may resolve to private addresses, e.g. a LAN-hosted app behind split DNS; nothing else is opened; see [docs/launches.md](docs/launches.md#where-webhooks-may-go) |
1998
2074
 
1999
2075
  Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
2000
2076
  defaults. Speech: phone settings > hub.json > env vars. Values are checked like the phone's speech
@@ -2282,7 +2358,7 @@ apart:
2282
2358
  - Stop on a subagent session's page stops its whole tree (as on the root's page).
2283
2359
  - Listening with the phone screen off depends on the browser (best-effort on Android Chrome; not
2284
2360
  possible on iOS). Replies and alerts still arrive as push notifications.
2285
- - The voice agent registers as a visible primary agent (`voice`) in OpenCode's agent list.
2361
+ - The voice front registers as a hidden primary agent (`voice-front`); OpenCode lists it with its hidden agents.
2286
2362
  - A catalog change still sends every machine's full catalog to each stream (about 1 KB per few sessions); the
2287
2363
  replay buffer is in memory (a hub restart means a full catalog + `resync`).
2288
2364
  - Running shells are seeded from the spoke's own server (`shell.list` for the 20 most recent session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
5
5
  "license": "MIT",
6
6
  "author": "Huy Le",
@@ -71,7 +71,8 @@
71
71
  "packages/codex/package.json",
72
72
  "bin",
73
73
  "web/dist",
74
- "web/src/voice/turn/LICENSE",
74
+ "packages/voice-client/src/vad/LICENSE",
75
+ "packages/voice-client/src/turn/LICENSE",
75
76
  "docker",
76
77
  "Dockerfile",
77
78
  "scripts/hub-local.sh",
@@ -98,7 +99,8 @@
98
99
  "packages/claude-code",
99
100
  "packages/conformance",
100
101
  "packages/preset",
101
- "packages/codex"
102
+ "packages/codex",
103
+ "packages/voice-client"
102
104
  ],
103
105
  "engines": {
104
106
  "bun": ">=1.4.4"
@@ -108,8 +110,9 @@
108
110
  },
109
111
  "scripts": {
110
112
  "test": "bun test",
111
- "typecheck": "tsc --noEmit && tsc --noEmit -p web",
113
+ "typecheck": "tsc --noEmit && tsc --noEmit -p web && tsc --noEmit -p packages/voice-client",
112
114
  "build:web": "vite build --config web/vite.config.ts",
115
+ "build:voice-client": "vite build --config packages/voice-client/vite.config.ts",
113
116
  "build:cli": "bun build packages/cli/src/main.ts --target=node --outfile=packages/cli/dist/hub-cli.js",
114
117
  "dev:web": "vite --config web/vite.config.ts",
115
118
  "test:e2e": "bash scripts/test-e2e.sh",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-claude-code",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",