@huynle/hub 0.9.2 → 0.10.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 (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -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 +81 -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-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /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,15 @@ 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
+ | `PUT /api/sessions/:id/model {model: {providerID, modelID, variant?}}` | (feature `setModel`; console only, same access as a prompt: the session's owner) sets the session's own model: its next turn and every later one run on it, whoever prompts (the console, the TUI, a Voice relay). The spoke calls OpenCode's `session.switchModel`, which stores it on the session. Answers `{model}` (the summary's `model`, source `chosen`). 400 malformed or not in the session's catalog (`GET …/models`), 409 `unsupported` from an older spoke. Audited `session.model`. The Voice model (`…/model-prefs`) is separate |
423
+ | `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 |
424
+ | `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) |
425
+ | `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 |
426
+ | `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
427
  | `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 |
428
+ | `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 |
429
+ | `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) |
430
+ | `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
431
  | `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
432
  | `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
433
  | `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 +466,20 @@ the console shows it in the session's Details rail, with Edit (live: the console
451
466
  Retention: every 6 h one hub drops the intents of deleted sessions and of sessions neither seen nor written for 30
452
467
  days; an online session's intent is always kept. A dropped intent's revision starts over at 0.
453
468
 
469
+ **Context cards.** Beside the intent, the hub keeps short, automatically updated [context cards](docs/context-cards.md)
470
+ per session, from a registry of providers: `pickup` (primary; a light model the hub calls writes "pick up where you
471
+ left off": a ≤ 90-character headline and a short summary) and `changes` (the working tree, no model). They are **opt-in per
472
+ user** (off until you turn them on in Settings › Concierge; nothing is sent to a model before). Then they run on turn
473
+ end, when a session gets blocked, when you come back to it, on a 30-minute sweep and on Refresh, only for sessions you
474
+ touched in the last 24 h, throttled, skipped when nothing changed and capped per user per day.
475
+ `GET /api/agent/sessions/:addr/context-cards` answers `{sessionID, cards, providers}` (users, `sessions:read`,
476
+ session tokens, like the intent read). New cards are `session.contextCard {provider, version, primary}` events,
477
+ never the text; the Concierge board shows each root's primary headline (`pick up «…»`).
478
+
454
479
  **Events.** `GET /api/agent/events?cursor=&wait=&limit=&type=&session=` reads a bounded per-user journal (24 h or
455
480
  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
481
+ `message.changed`, `launch.changed`, `session.archived`, `session.named`, `session.intent`, `session.contextCard`, `role.claimed`, `role.released`,
482
+ `subscription.created/removed`. Each is `{id, at, type, subject, data}` with IDs and states
457
483
  only: never prompts, message bodies, titles, blocker summaries, launch reasons or labels. Without a cursor the answer
458
484
  is empty with a cursor at the latest event; pass the returned `cursor` back to get what came after. `wait` (0–60 s)
459
485
  holds the request while nothing matches; `limit` is 1–200 (default 50); `type` (exact, or `launch.*`) and `session`
@@ -518,21 +544,28 @@ cookie and a hub bearer token (400).
518
544
  | `GET /api/agent/status` | ✓ | ✓ | ✓ | machines → projects → sessions, each with name, roles, state, harness, activity and blockers, plus `attention` |
519
545
  | `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
546
  | `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` |
547
+ | `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
548
  | `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
549
  | `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
550
  | `GET /api/agent/who?project=&role=` | ✓ | ✓ | ✓ | named, role-holding and online sessions |
551
+ | `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
552
  | `POST /api/agent/name {name, session?}` | ✓ | own session | `sessions:manage` | names a session (`409 name_taken`) |
526
553
  | `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
554
  | `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
555
  | `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
556
  | `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
557
  | `GET /api/agent/decisions?state=` · `…/decisions/:id` | ✓ (all) | own | `messages:read` (own) | the Decisions the caller asked |
558
+ | `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
559
  | `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
560
  | `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
561
  | `POST /api/agent/mute {session}` · `…/unmute` | ✓ | ✓ | `messages:send` | refuses (or allows again) messages from a session |
534
562
  | `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
535
563
  | `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
564
+ | `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 |
565
+ | `GET /api/agent/connections` · `DELETE …/:id` | ✓ | – | – | your connected apps (scopes, created, last refresh); delete revokes one (its tokens, grants and webhooks) |
566
+ | `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 |
567
+ | `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`) |
568
+ | `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
569
 
537
570
  Sends to a session (`msg`, `ask`, `reply`, `decline`) answer `{message, delivery, woke, wakeSkipped?}`: `woke` says
538
571
  whether the hub sent it to wake the recipient, and when not, `wakeSkipped` says why (`wake_false`, `subagent`,
@@ -567,7 +600,26 @@ decision}` on every open, answer, timeout, escalation and expiry); a `decision`
567
600
  Reads come from the hub's catalog; only `show` (summary) and `log` (timeline) ask the session's spoke, so they
568
601
  depend on what its harness supports. A session token reads all of its user's sessions but names and claims roles
569
602
  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.
603
+ minClientVersion, auth, features}` for apps and CLIs; `features` lists `app-connect` (apps connect with consent:
604
+ [docs/app-front.md](docs/app-front.md#connect-an-app)), `app-speech` (speech for apps:
605
+ [docs/app-front.md](docs/app-front.md#speech-for-apps)), `app-conversation`
606
+ ([conversation for apps](docs/app-front.md#conversation-for-apps)) and `app-decisions`
607
+ ([Decisions from apps](docs/app-front.md#decisions-from-apps)).
608
+
609
+ **Connected apps.** A web app's server connects to a user's account with an OAuth 2.1-style consent flow instead of a
610
+ hand-made token: `GET /connect/authorize` (PKCE S256) → the hub's consent page, where the user can narrow the scopes →
611
+ `POST /connect/token` → a 1-hour `hca_` access token and a rotating `hcr_` refresh token; `POST /connect/revoke` ends
612
+ it. Clients are registered in Settings → Agents & access. See [docs/app-front.md](docs/app-front.md#connect-an-app).
613
+
614
+ **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
615
+ helper sessions, follow and talk to them (a conversation stream, voice turns through the voice front), speak and
616
+ transcribe with the user's own speech provider, and answer the everyday confirm-tier Decisions its own sessions ask.
617
+ Always through the app's **own server** (a BFF): hub tokens never reach a browser, and the hub sends no CORS headers.
618
+ The guide: [docs/app-front.md](docs/app-front.md) (the BFF pattern, scopes, the grants each feature needs, limits,
619
+ the refresh rule, security rules). The SDK (`packages/sdk`: `HubConnect`, `ConnectionTokens`, `hub.speech`,
620
+ `hub.conversation`, `hub.appDecisions`) and the voice loop's `appTransport` (`packages/voice-client`) do the work; a
621
+ reference BFF lives in [examples/app-front-bff](examples/app-front-bff/README.md), and the
622
+ [app fixture](docs/app-integration-testing.md) runs a real hub to test an app against.
571
623
 
572
624
  **Apps.** A native app links with a code from Settings → Devices → **Add app** (`POST /api/devices/link`, then
573
625
  `POST /api/devices/redeem` from the app; see [Multi-user](#multi-user-sign-in-with-google)) and uses the device
@@ -592,7 +644,7 @@ you. Addresses resolve among your own sessions only; another user's sessions do
592
644
  | `reply <msgID> "text"` · `decline <msgID> --why "…"` | answers a message you received; it goes to the message's real sender, never through the role |
593
645
  | `inbox` | asks waiting for your answer, your own open asks, recent messages, mutes |
594
646
  | `mute <addr>` · `unmute <addr>` | refuses (or accepts again) messages from a session (`forbidden`, exit 5) |
595
- | `who` | named and role-holding sessions |
647
+ | `who` | online sessions, and named or role-holding ones while offline |
596
648
 
597
649
  `--no-wake` admits a message without starting a turn and `--urgent` steers it into a running turn. Texts are at
598
650
  most 10,000 characters (a decline's reason 1,000).
@@ -643,7 +695,8 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
643
695
  **Where agents send from.**
644
696
  - In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
645
697
  - 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
698
+ `whoami`, the [team intake](docs/teams-intake.md) verbs `intake` and `intake_update`, the
699
+ [subscription](docs/subscriptions.md) verbs `subscribe`, `unsubscribe` and `subscriptions`, `name`, `role_claim` and
647
700
  `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
701
  - You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
649
702
  - Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with, or the hub's
@@ -792,7 +845,7 @@ and the SDK. Only you manage grants: the console (Settings → Agents) or the CL
792
845
  | `POST /api/agent/sessions/:addr/approve {requestID, always?, why?}` | ✓ | under a grant | under a grant |
793
846
  | `POST /api/agent/sessions/:addr/deny {requestID, why?}` | ✓ | under a grant | under a grant |
794
847
  | `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 |
848
+ | `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
849
  | `POST /api/agent/sessions/:addr/stop {why?}` | ✓ | under a grant | under a grant |
797
850
  | `POST /api/agent/grants` · `GET` · `GET`/`PUT`/`DELETE …/grants/:id` | ✓ | – | – |
798
851
  | `POST /api/agent/grants/requests {grant, why}` | – | ✓ (any token) | `messages:send` |
@@ -867,7 +920,7 @@ hub show <addr> state, blockers, activity, summary
867
920
  hub log <addr> [-n 20] newest timeline rows (1–200), printed oldest first
868
921
  hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pings skipped)
869
922
  hub whoami who this credential is
870
- hub who [--project P] [--role R | @@role] named and role-holding sessions
923
+ hub who [--project P] [--role R | @@role] online sessions, and named or role-holding ones while offline
871
924
  hub name <@name> [--session <addr>]
872
925
  hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters)
873
926
  hub role claim <role[.project]> [--exclusive] [--session <addr>]
@@ -881,6 +934,9 @@ hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…
881
934
  hub decisions list [--state open|answered|expired|closed] the Decisions you asked
882
935
  hub inbox asks to answer, your open asks, recent messages, mutes
883
936
  hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
937
+ hub subscribe <addr|@@role|#team|children>... [--type T]... [--delivery queue|steer|silent] [--batch 30s]
938
+ [--expires 1d] [--max-per-hour N] [--subscriber <addr>] batched hub messages about their events
939
+ hub subscriptions [--session <addr>] · hub unsubscribe <subID>
884
940
  hub approve <addr> <requestID> [--always] [--why "…"] answer a permission request (once, or always)
885
941
  hub deny <addr> <requestID> [--why "…"] reject it; the reason reaches the harness
886
942
  hub answer <addr> <formID> key=value… | --json '{…}' | --json - | --cancel [--why "…"]
@@ -1113,7 +1169,7 @@ event stream comes back) it asks its own server what runs now (`session.active`
1113
1169
 
1114
1170
  | Entry | File | Role |
1115
1171
  |---|---|---|
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 |
1172
+ | 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
1173
  | 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
1174
  | 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
1175
 
@@ -1122,10 +1178,12 @@ The plugin ids are `hub` (server) and `hub.tui`. OpenCode resolves a local plugi
1122
1178
 
1123
1179
  - **Config**: `~/.config/hub/config.json` (or `$HUB_SPOKE_CONFIG`), shared by all
1124
1180
  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_.-]+`),
1181
+ `voiceModel` (the voice front's model, `provider/model[#variant]`; pick a fast paid one),
1126
1182
  `autoStartLocalHub` (default `true`; see [Local only](#local-only)), `herdr` (default `true`; read open
1127
1183
  sessions and workspaces from [Herdr](#open-sessions-herdr-and-tuis)), and `list` (the history window,
1128
1184
  default `{"days": 30, "max": 100}`; days 1–365, max 1–500; out-of-range values fall back per field).
1185
+ `subagent` and `workModel` (the removed voice agent's delegate agent and worker model) are obsolete: read
1186
+ without error, ignored, logged once per process, and dropped the next time the plugin saves the file.
1129
1187
  - **Modes**: `off` (no connection), `always` (every session this process knows, Voice on for all),
1130
1188
  `session` (Voice only for sessions switched on in `/hub` or from the phone, plus their subagent
1131
1189
  sessions). Every reported session can be inspected and controlled in both modes; in `session` mode the
@@ -1194,20 +1252,34 @@ Sessions open in a terminal are reported with `open: true` and `terminal` (`src/
1194
1252
 
1195
1253
  ## Conversation mode (OpenCode side)
1196
1254
 
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.
1255
+ Voice mode (conversation mode) always uses the **voice front** when it can: a hidden, hub-managed OpenCode session
1256
+ on the fast `voiceModel`, one per conversation (design: `docs/plans/2026-11-06-voice-front-design.md`, contract:
1257
+ `docs/plans/2026-11-06-voice-front-p1-contract.md`). Every reply starts with a short echo of what it understood;
1258
+ it relays your request to the session (a new turn when the session is idle, steering when it is busy, queued for
1259
+ an unrelated request) or stops it, and speaks the session's results, questions and failures. It has no shell, no
1260
+ edits and no subagents. The session you talk to keeps its own agent, model, tools and subagents.
1261
+
1262
+ The front's model requests are kept short so its echo starts at once (`src/spoke/voice-front.ts`, applied by the
1263
+ `context` hook to linked fronts only):
1264
+ - **No thinking:** an Anthropic-API model (also through a gateway such as OmniRoute) gets `thinking: {type:
1265
+ "disabled"}`, overriding a model's own thinking setting or a `#variant`. An OpenAI-style model gets the lowest
1266
+ reasoning effort it lists (`none`, else `minimal`); one that lists neither gets nothing, because a non-reasoning
1267
+ model may reject `reasoning_effort`. Other providers keep their default.
1268
+ - **Lean system prompt:** only the front's own prompt. OpenCode's instruction files (`AGENTS.md`, `CLAUDE.md`,
1269
+ `instructions`), model, date, environment and worktree blocks are left out; the front gets its two tools, the
1270
+ per-turn note and its conversation.
1271
+
1272
+ Without a front the hub talks to the session directly and the session answers in short spoken replies (the
1273
+ `context` hook adds a voice style to its turns): when the device turned **Voice front** off (Settings → Voice), when
1274
+ the machine's plugin is older or cannot create the front, after the front failed (`front_failed`, e.g. a free model
1275
+ out of quota), and when an older hub sends `conversation {on}` without `front`. In no case is the session's agent or
1276
+ model switched. Earlier versions switched the session to a `voice` agent on the fast model that delegated work to
1277
+ background subagents; that agent, its orchestrator prompt, the `hub_task_stop` tool and the `subagent` /
1278
+ `workModel` settings are gone.
1279
+
1280
+ The phone composer has one **Voice** model control per session: the voice front's model. The picker lists the
1281
+ connected OpenCode process's models and variants; the choice is saved per user and session, applies the next
1282
+ time Voice mode starts, and falls back to the machine's `voiceModel` when unset. Pick a fast, paid model.
1211
1283
 
1212
1284
  The conversation is an inline execution timeline: streamed thinking, tools and nested subagents appear
1213
1285
  between messages as compact expandable rows. Rows keep the session's order, never wall-clock order: history
@@ -1220,26 +1292,19 @@ not persist those potentially sensitive details.
1220
1292
 
1221
1293
  **Spoke side** (`src/spoke/conversation.ts`, `src/spoke/spoke.ts`):
1222
1294
 
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.
1295
+ - The plugin registers the hidden primary agent **`voice-front`** through `ctx.agent.transform` (everything denied
1296
+ but its two relay tools, `voice_main_send` and `voice_main_stop`, which the `context` hook offers only to a linked
1297
+ front). It registers no `voice` agent: a session an older plugin left on one (a crash mid-conversation) is moved
1298
+ to the default agent before the hub's next prompt, or after a turn fails with "Agent not found", because OpenCode
1299
+ 2.2.3 fails every turn on a missing agent. A `voice` agent you define yourself is left alone.
1300
+ - `conversation on` with `front` creates (or re-links) the front; `on` without it, or when no front can be made,
1301
+ marks the session's turns as voice turns. `off` deletes the front. Both answer with the session's current agent
1302
+ and never switch it.
1303
+ - **Task board**: the front's per-turn note about its session lists the session's workers (`src/spoke/taskboard.ts`,
1304
+ from this process's events): title, state (working, blocked with what it waits for, done with its latest text,
1305
+ failed with the reason, stopped) and for how long, at most 8 (live ones first) of at most 200 characters each.
1306
+ So "what's running?" or "how's the deploy going?" needs no tool call. The phone's stop words ("stop", "cancel",
1307
+ "never mind", "stop that", "hold on") still interrupt the turn at once.
1243
1308
  - **Messaging other sessions**: the plugin adds a tool `hub {verb, …}` with the verbs `msg`, `ask`, `reply`,
1244
1309
  `decline`, `inbox`, `who`, `status`, `whoami`, `intake` and `intake_update` (`src/spoke/hub-tool.ts`). It acts as the calling session
1245
1310
  with that session's own hub token (the token never appears in the tool's output). `name {name}` sets the
@@ -1251,8 +1316,9 @@ not persist those potentially sensitive details.
1251
1316
  - Child text and execution events are paired by the hub. The latest assistant message of a successful
1252
1317
  child is emitted as root-session speech; interruptions and stale partial results are not. **Announcements**
1253
1318
  (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
1319
+ the last minute, a result starts with the task's title ("Deploy check finished: …"); a result the session
1320
+ already said in the last two minutes is not read again (with a voice front, worker results are the front's to
1321
+ report and the hub does not read them). A failed worker is announced once with its reason
1256
1322
  ("Lint failed: rate limit exceeded."), a worker's permission request or question once per request ("Cleanup
1257
1323
  needs your permission.", "… has a question."); a stopped worker is not. Announcements are ordinary `say`s
1258
1324
  without `seq` (`messageID` `announce:failed:<session>`, `announce:permission:<id>`, `announce:form:<id>`),
@@ -1260,13 +1326,13 @@ not persist those potentially sensitive details.
1260
1326
  - Server ops: `steer` sends the text with `delivery: "steer"` (metadata `{source: "hub", mode:
1261
1327
  "conversation"}`, or `mode: "remote"` for a session not shared with Voice; 0.5 wrote `source:
1262
1328
  "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
1329
+ interrupts the session; `conversation` turns the mode on/off and returns the session's agent (never
1330
+ switched), and is refused for sessions not shared with Voice. Replies reach the hub as forwarded
1265
1331
  `session.text.ended`, `session.inbox.delivered` and `session.agent.selected` events.
1266
1332
  - 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.
1333
+ - From the terminal: `/voice` opens the current session in the browser with Voice mode starting (with the voice
1334
+ front, unless that browser turned it off); `/hub → Conversation mode` only toggles short spoken replies for the
1335
+ session in this process (no front), then offers to send the session to your phone.
1270
1336
 
1271
1337
  ## Central server
1272
1338
 
@@ -1293,8 +1359,10 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
1293
1359
  | `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
1360
  | `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
1361
  | `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) |
1362
+ | `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
1363
  | `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) |
1364
+ | `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 |
1365
+ | `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
1366
  | `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
1367
 
1300
1368
  The Docker image installs only the hub's runtime packages (web-push and its dependencies, pinned by
@@ -1344,8 +1412,10 @@ recent activity.
1344
1412
  `web/` is the Hub console, a Solid + Vite + TypeScript PWA built to `web/dist` (`npm run build:web`;
1345
1413
  the Docker image, npm package and binaries ship the build). One store fed by the hub's SSE stream drives:
1346
1414
 
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
1415
+ - **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, **Working set** (your pinned
1416
+ sessions and the 7 you touched most recently within 7 days; Pinned when you touched none), Running, sessions open
1417
+ in a terminal grouped by project (or server) with `+`, Recents. Rows show the session's intent as a muted second
1418
+ line, and Needs you rows how long they have waited. Each section collapses from its heading (remembered on
1349
1419
  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
1420
  title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
1351
1421
  (rename, pin, archive, stop, Allow/Disallow Voice).
@@ -1400,7 +1470,8 @@ the Docker image, npm package and binaries ship the build). One store fed by the
1400
1470
  recording cancels it; each session keeps its own unsent draft), Voice mode (hands-free, see below; a toggle that
1401
1471
  turns Voice on first when needed; one session at a time, so turning it on elsewhere turns the first one off; it
1402
1472
  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
1473
+ microphone picker, the Voice model menu (the voice front's model) and the Model chip (the session's own model; where
1474
+ the machine's plugin serves `setModel` it opens the same menu and sets the model every later turn runs on). The right rail (a bottom sheet on phones) has
1404
1475
  cards that collapse from their header (remembered per device): **Environment** (machine with
1405
1476
  online/offline/Connecting, project folder with the full path as tooltip, branch, and where it is open:
1406
1477
  TUI, Herdr or background), Context, **Connectors · N connected** (MCP servers) and Changes with a diff
@@ -1685,7 +1756,7 @@ public URL, `localhost`, `127.0.0.1` and `[::1]` with the same scheme and port c
1685
1756
  |---|---|---|
1686
1757
  | `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
1758
  | `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 |
1759
+ | `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
1760
  | `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
1761
  | `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
1762
  | `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 +1850,13 @@ around the mic with the current state: Listening, Hearing you, Transcribing, Sen
1779
1850
  it again, say "pause listening", or leave the page to turn it off. Leaving the page calls
1780
1851
  `{on:false}` with `keepalive`. While conversation mode is off, tap-to-talk works as before.
1781
1852
 
1782
- - **Speech detection** (`web/src/core/speech-vad.ts`; ~20 ms frames from `web/public/vad-worklet.js`, with a
1853
+ The loop is the package `packages/voice-client` (a `VoiceTransport` over the console's routes and SSE,
1854
+ `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)).
1855
+
1856
+ - **Speech detection** (`packages/voice-client/src/core/speech-vad.ts`; ~20 ms frames from `web/public/vad-worklet.js`, with a
1783
1857
  ScriptProcessor fallback): on-device [Silero VAD](https://github.com/snakers4/silero-vad) v5 (MIT) through
1784
1858
  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
1859
+ (`packages/voice-client/src/silero-worker.ts`). The mic audio is resampled to 16 kHz and cut into 32 ms chunks; each
1786
1860
  chunk's speech probability starts speech at ≥ 0.6 sustained for 250 ms (most of the onset voiced, so
1787
1861
  isolated clicks never add up) and ends it after 350 ms below 0.35. Typing, a fan, clatter, beeps and hum
1788
1862
  score low; speech scores high at any loudness. An energy detector stays as a cheap pre-gate (chunks within
@@ -1798,7 +1872,7 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1798
1872
  - **Echo guard**: while a reply or a progress cue plays, and 400 ms after, a barge-in needs more: the model's
1799
1873
  probability ≥ 0.85 (Low 0.92, High 0.75) and 8 dB more energy. The browser's echo cancellation removes most
1800
1874
  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
1875
+ - **Noise transcripts** (`packages/voice-client/src/core/junk-transcript.ts`): what speech-to-text makes of noise is dropped
1802
1876
  before it reaches the agent and shown dim in the Voice bar for 3 s ("ignored: “Thank you.”"): empty or
1803
1877
  punctuation-only text, sound tags ("[Music]", "(applause)", "♪"), Whisper's stock phrases ("Thank you.",
1804
1878
  "you", "Bye.", "Thanks for watching!", "Subtitles by …", "Amara.org" …), one word repeated, and one or two
@@ -1814,10 +1888,10 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1814
1888
  - **Turn detection (experimental, Settings → Voice → Turn detection, per device)**: "Timers (current)" is the
1815
1889
  default and works as above. "Smart Turn (experimental)" decides the end of a turn from the audio: on-device
1816
1890
  [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
1891
+ first use as a hashed asset; license in `packages/voice-client/src/turn/LICENSE`) in its own Web Worker
1892
+ (`packages/voice-client/src/smart-turn-worker.ts`, onnxruntime-web as for Silero, so the console policy needs nothing new). The
1819
1893
  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'
1894
+ computes Whisper's log-mel features in TS (`packages/voice-client/src/core/smart-turn-features.ts`, matched to transformers'
1821
1895
  `WhisperFeatureExtractor` within 1e-5) and scores it at each segment end: a score ≥ 0.5 sends 150 ms after the
1822
1896
  score once the transcripts are in; a lower one holds and is re-scored every 400 ms of quiet, with a fallback after
1823
1897
  3 s. Speech resuming cancels the send as with the timers; commands, the 60 s cap and noise transcripts are
@@ -1837,7 +1911,7 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1837
1911
  list items are handled, and fenced code becomes "(code omitted)". Each sentence goes to `/api/speak`,
1838
1912
  up to two ahead of playback, and plays through one `<audio>` element while its MP3 is still downloading:
1839
1913
  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
1914
+ (`packages/voice-client/src/core/audio-stream.ts`). Browsers without MP3 in MediaSource (some iOS Safari versions) play each
1841
1915
  sentence once it has fully downloaded, as before. Prefetched sentences keep downloading while they wait. Speech while a reply plays
1842
1916
  (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
1917
  being written whose next sentence has not arrived yet (their later sentences still show in the transcript); a
@@ -1876,7 +1950,7 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1876
1950
 
1877
1951
  ### Voice feedback (phone)
1878
1952
 
1879
- Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/core/cues.ts` plays cues.
1953
+ 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
1954
 
1881
1955
  - **Voice speed**: 0.75×–2.0× in 0.05 steps, next to "Read replies aloud" and in Settings → Voice
1882
1956
  (default 1.0×, stored per device in `localStorage`). It is applied on the phone as the audio
@@ -1896,7 +1970,8 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1896
1970
  newest are at the bottom, and it keeps about 30. The agent's reply streams into its bubble (one bubble per
1897
1971
  assistant message); in conversation mode the spoken `say` text is merged into the bubble of the same message.
1898
1972
  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
1973
+ - **Progress cues** (conversation mode; Settings → Voice → "Progress cues", on by default; with the voice front
1974
+ only the tones play, its echo replaces the spoken phrases below): a soft
1900
1975
  tone as soon as you stop speaking (the moment the segment goes to transcription; typed text gets it when
1901
1976
  the agent receives it), then a short acknowledgement ("On it.", "One moment.", "Let me check." …) if the
1902
1977
  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 +1980,7 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1905
1980
  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
1981
  play while you are talking or the agent's speech is playing; they wait, and are dropped once the agent
1907
1982
  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
1983
+ playback changes. Each cue has several variants (`CUE_VARIANTS` in `packages/voice-client/src/core/feedback-core.ts`), chosen
1909
1984
  from a shuffle-bag: every variant once before any repeats, never the same phrase twice in a row, and a variant
1910
1985
  whose audio is already loaded wins. All variants are synthesized, 2 at a time, when Voice mode starts (ending Voice mode
1911
1986
  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 +1996,7 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1921
1996
  (`localStorage` `voice.replyLoudness`, clamped to −26…−14 LUFS) becomes the target. Audio that cannot be decoded
1922
1997
  plays as synthesized. Cues and replies keep their plain `<audio>` elements (no Web Audio playback graph, which
1923
1998
  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`.
1999
+ `packages/voice-client/src/core/loudness.ts`; browser decoding: `packages/voice-client/src/cue-loudness.ts`.
1925
2000
  - **Presence**: while a session view is open, the phone posts `POST /api/sessions/:id/presence
1926
2001
  {visible}` on visibility changes and every 30 s while visible, and `{visible:false}` on `pagehide` and
1927
2002
  when leaving the view. The hub uses this to decide when to push.
@@ -1946,7 +2021,7 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
1946
2021
  device cookie. It closes when Voice mode ends or the page goes away. It needs notification permission:
1947
2022
  without it the Voice bar shows **Allow notifications**. The Media Session shows "Voice mode on" with the
1948
2023
  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`).
2024
+ `packages/voice-client/src/core/voice-notifier.ts`).
1950
2025
  - **Settings → Speech** (your own settings; admins also edit the server default): cards for Speech-to-text and Text-to-speech, with provider
1951
2026
  (OpenRouter / OpenAI-compatible), base URL (OpenAI-compatible only), model, voice (TTS), language (STT,
1952
2027
  optional), server-side speed (TTS, optional, 0.25–4) and API key. Model and voice are buttons that open a
@@ -1995,6 +2070,8 @@ in the file).
1995
2070
  | `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
2071
  | `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
2072
  | `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) |
2073
+ | `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) |
2074
+ | `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 |
1998
2075
  | `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) |
1999
2076
 
2000
2077
  Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
@@ -2283,7 +2360,7 @@ apart:
2283
2360
  - Stop on a subagent session's page stops its whole tree (as on the root's page).
2284
2361
  - Listening with the phone screen off depends on the browser (best-effort on Android Chrome; not
2285
2362
  possible on iOS). Replies and alerts still arrive as push notifications.
2286
- - The voice agent registers as a visible primary agent (`voice`) in OpenCode's agent list.
2363
+ - The voice front registers as a hidden primary agent (`voice-front`); OpenCode lists it with its hidden agents.
2287
2364
  - A catalog change still sends every machine's full catalog to each stream (about 1 KB per few sessions); the
2288
2365
  replay buffer is in memory (a hub restart means a full catalog + `resync`).
2289
2366
  - 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.2",
3
+ "version": "0.10.1",
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.2",
3
+ "version": "0.10.1",
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",