@huynle/hub 0.7.4 → 0.8.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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
package/README.md CHANGED
@@ -46,6 +46,9 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
46
46
  Bring your own speech: OpenRouter or any OpenAI-compatible STT/TTS service (including self-hosted).
47
47
  - **What's open in your terminals first**: sessions open in Herdr or the OpenCode TUI, grouped like
48
48
  Herdr's sidebar, with the rest under Recent; rename, pin and archive in sync across devices.
49
+ - **Fork and revert a message** (OpenCode): hover one of your messages for Copy, Fork (a new session with the
50
+ conversation before it, the message in its composer) and Revert (hide it and what follows, restore the files the
51
+ session changed, edit and resend; Restore undoes it) ([docs/session-view.md](docs/session-view.md#fork-and-revert-a-message)).
49
52
  - **Agent identity**: every session's shells carry its own hub identity, so an agent can run `hub status`, name
50
53
  itself (`@scout`), claim a role (`@@reviewer`) and read other sessions, with app tokens for scripts and device
51
54
  tokens for apps ([Agent identity](#agent-identity-agent-api)).
@@ -60,6 +63,23 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
60
63
  each harness's spoke serves, with an evidence badge and honest delivery labels
61
64
  ([docs/harnesses.md](docs/harnesses.md)). Write a spoke for another harness on the spoke SDK and check it with the
62
65
  conformance suite ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
66
+ - **Workspaces and supervision**: group sessions from any machine into a durable workspace, drag them under a
67
+ master or supervisor (separate from native subagent ancestry), track explicit assignments on a situation board,
68
+ replace a master explicitly, and keep supervisors informed with batched digests and durable checkpoints
69
+ ([docs/workspaces.md](docs/workspaces.md)).
70
+ - **Split panes and side chats**: up to 4 sessions or artifacts side by side, and side chats started from a bounded
71
+ copy of a session that never interrupt it ([docs/workspaces.md](docs/workspaces.md#side-chats)).
72
+ - **Agent profiles**: shared, versioned instructions, skills and commands, pinned to a session, launch or team role at
73
+ runtime, with an honest per-harness acknowledgment ([docs/profiles.md](docs/profiles.md)).
74
+ - **Artifacts with AI**: persistent, revisioned documents, tables, charts, dashboards and files in an **Artifacts**
75
+ library. AI jobs ask about or edit them through an agent you choose, and interactive apps run only in an isolated
76
+ sandbox host ([docs/artifacts.md](docs/artifacts.md), [docs/artifact-host.md](docs/artifact-host.md)).
77
+ - **Teams**: roles, an entry role that takes work, and enforced one-way or two-way communication paths (or open
78
+ mode), staffed with sessions from any machine. The console's **Teams** page lists them and shows each team's
79
+ members, work (live intake status) and communication ([docs/teams.md](docs/teams.md),
80
+ [intake](docs/teams-intake.md)).
81
+ - **Watchtower** (formerly Bird's-eye view): a 3D, 2D or list picture of machines, sessions, teams and live
82
+ message flow, with a team editor ([docs/watchtower.md](docs/watchtower.md)).
63
83
  - **Session details**: context usage, todos, MCP status, branch and per-file diffs, on demand; unread
64
84
  marks synced per user.
65
85
  - **Self-hostable**: a hub on your computer with nothing extra to install, or your own hub for phones
@@ -83,6 +103,29 @@ opencode plugin add @huynle/hub
83
103
  opencode service restart
84
104
  ```
85
105
 
106
+ OpenCode 2.x loads plugins per project, on the first request that opens one, and has no hook at server start: a
107
+ freshly (re)started background service or `opencode serve` has no hub connection, so its machine shows **offline**
108
+ until a client (the TUI, the web app) opens a project. OpenCode also unloads a project after an hour without session
109
+ activity, and the hub plugin with it. `hub node` ([launches](docs/launches.md#hub-node)) keeps the background service
110
+ warm by itself (cheap location reads every 30 s), and any foreground `opencode serve` you list in node.json:
111
+
112
+ ```json
113
+ { "workspaceRoots": ["/Users/you/projects"],
114
+ "opencode": { "servers": [{ "url": "http://127.0.0.1:4096", "passwordFile": "/Users/you/.config/hub/opencode-4096.pw" }] } }
115
+ ```
116
+
117
+ (the server's `OPENCODE_PASSWORD` in a `chmod 600` file; loopback URLs only). To stop OpenCode unloading projects at
118
+ all, set `"experimental": { "location_idle_timeout": false }` in your OpenCode config (or a longer duration such as
119
+ `"8 hours"`). Without `hub node`, open OpenCode once, or send the read yourself, e.g. for a foreground
120
+ `opencode serve --port 4096` with `OPENCODE_PASSWORD` set:
121
+ `curl -fsS -u "opencode:$OPENCODE_PASSWORD" http://127.0.0.1:4096/api/agent >/dev/null`.
122
+
123
+ Which sessions show **online**: the background service reports all of its recent sessions; a foreground
124
+ `opencode serve` with `OPENCODE_PASSWORD` (or `OPENCODE_SERVER_PASSWORD`) in its environment reports every recent
125
+ session of the projects it has loaded, idle ones included (so hub messages reach them at once); a TUI's own server,
126
+ or a foreground server started without a password in its environment (OpenCode then makes one up the plugin cannot
127
+ read), reports only the sessions that were active in that process.
128
+
86
129
  Install from npm: the npm package ships the built web app, so the plugin can run a local hub. A GitHub
87
130
  install (`opencode plugin add github:huynle/hub`) has no built web app (`web/dist` is not in the
88
131
  repository): it works for connecting to a hub that runs elsewhere, but it does not start a local hub
@@ -151,6 +194,11 @@ curl -fsSLO https://github.com/huynle/hub-releases/releases/download/v<version>/
151
194
  # 2. Enroll this machine (device code: approve it on the hub; Ctrl-C cancels). One identity for every spoke here.
152
195
  ./hubctl machine enroll --server https://hub.example.com
153
196
  ./hubctl machine status # verified | revoked (exit 9) | offline (exit 7) | insecure (plain-http hub)
197
+ # A plain-http hub on your LAN (a preview at http://10.x / 192.168.x): refused unless you opt in, every time, with a
198
+ # warning; only for private-network IP addresses, never a public address or a host name (`hub login` takes it too):
199
+ # ./hubctl machine enroll --server http://192.168.1.20:4097 --allow-insecure-http
200
+ # (the spoke and hub node then use the machine token over plain http; `hub login --allow-insecure-http` likewise
201
+ # stores an app token that every later `hub` command sends unencrypted, with a warning each time)
154
202
 
155
203
  # 3. Preview: discover the host and print the exact plan and any manual edits. It writes nothing.
156
204
  ./hubctl setup preview --profile session-only --manifest hubctl-manifest.json --out preview.json
@@ -258,7 +306,7 @@ volumes:
258
306
  The image sets `HUB_HOST=0.0.0.0`, `HUB_PORT=4097` and `HUB_STATE_DIR=/data`. Build it locally with
259
307
  `docker build -t hub .`.
260
308
 
261
- **bunx / from source.** `bunx --package @huynle/hub hub-server` (needs Bun ≥ 1.3), or in
309
+ **bunx / from source.** `bunx --package @huynle/hub hub-server` (needs Bun ≥ 1.4.4), or in
262
310
  a checkout: `npm install --no-audit --no-fund && bun bin/hub.ts`. `scripts/hub-local.sh` runs it in the
263
311
  foreground with `hub.json`; `scripts/io.github.huynle.hub.plist` is a launchd template. The
264
312
  `opencode-hub` and `opencode-voice-hub` commands still work as aliases of `hub-server`.
@@ -270,6 +318,11 @@ front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, `/.well-known
270
318
  icons reachable for spokes, sign-in, app discovery and the PWA (and `/mcp`, bearer-authenticated, if MCP clients
271
319
  or gateways use [MCP over HTTP](#over-http-mcp)).
272
320
 
321
+ **Behind a reverse proxy**, set a request-body limit of about 26 MiB (artifact blobs are up to 25 MiB) and keep
322
+ request streaming on: only the proxy closes a socket a client keeps sending on after the hub answered
323
+ ([docs/operations.md](docs/operations.md#request-bodies-and-the-front-proxy)). Back up the whole `HUB_STATE_DIR`,
324
+ including `blobs/` (artifact bytes).
325
+
273
326
  ## Configuration
274
327
 
275
328
  - **Hub**: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`, `OPENROUTER_API_KEY`, …)
@@ -301,11 +354,12 @@ conversation mode and utterances (403 `not_exposed` for sessions not shared with
301
354
  | `GET /api/sessions/:id/timeline/messages/:message/files/:index` | one image attached to a user message, as bytes with its type (`cache-control: private, no-store`). Timeline user items list their attachments as `files: [{index, mime, name?}]` (never the bytes). Every timeline row carries `pos` (ordering position: session message order, then part order; derived from the message's creation time, kept increasing, so it does not change as pages move); tool rows `callID`, subagent tool rows `childSessionID` and `title` (the task description), assistant text rows `ordinal`. Only PNG, JPEG, WebP and GIF are served (404 otherwise or when missing); 413 when the image would not fit in a protocol frame (about 650 KB) |
302
355
  | `POST /api/sessions/:id/rename` `{auto: true}` | Auto-name: OpenCode regenerates the title with its Title agent (`session.update` with an empty title on the process's own server). 200 `{title}` (or `{title, unchanged: true}` when OpenCode kept it), 202 `{pending: true}` when it is still generating after 20 s (the catalog updates when it lands), 409 `plugin_outdated` when no process reporting the session supports it, 502 when the process cannot reach its server or the title model failed |
303
356
  | `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) |
357
+ | `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) |
304
358
  | `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) |
305
359
  | `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 |
306
360
  | `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 |
307
361
  | `POST /api/sessions/:id/read` · `GET /api/reads` | per-user read marks, synced to your other devices |
308
- | `GET /api/events?lite=1&session=<id>&last=<id>` | the live stream (SSE). Every message has an `id:`; reconnecting with `Last-Event-ID` (EventSource does it) or `?last=` replays what was missed when the hub still has it (last 2000 events or 5 minutes per user; one fresh `catalog` stands for any catalog changes), else sends the catalog then `{type: "resync"}` (the page refetches what it shows). Ids are `<epoch>.<n>`; a hub restart starts a new epoch. `lite=1` leaves out `delta`/`reasoning` except of `session`'s tree (status, activity, catalog, turns and the rest still come). Heartbeat: `event: ping` every 20 s |
362
+ | `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 |
309
363
  | `GET /api/push/prefs?endpoint=` | `{prefs, quietHours, activityMinutes, muted, subscriptions, subscribed?}`: this device's toggles (`handoff, question, permission, done, failed, turn, stopped, retry, subagentDone, subagentFailed, jobDone, jobFailed, longTool, say, activity`; `PUT` also accepts the legacy `finished` → `done` and `blocked` → question + permission), activity update interval and quiet hours, the user's muted sessions, how many subscriptions the hub holds for this device, and whether it holds `endpoint` |
310
364
  | `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) |
311
365
  | `POST /api/push/subscribe {subscription, prefs?, oldEndpoint?}` · `POST /api/push/unsubscribe {endpoint}` | this device's subscriptions; `oldEndpoint` is dropped (rotated subscription). The endpoint must be https on a public hostname that resolves only to public addresses (no loopback, private, link-local, CGNAT or ULA hosts, no `localhost`/`*.local`/`*.internal`), else 400 |
@@ -321,12 +375,31 @@ MCP exposes the same operations as `archive` and `unarchive`. Naming (`POST /api
321
375
  token with `sessions:manage`; role claims and releases take one with `sessions:manage` **and** a grant allowing
322
376
  `roles` whose projects cover the session's directory (the session must be online), under the kill switch and hourly
323
377
  cap, with every outcome in the agent audit (verbs `roles` and `roles.release`). Reserved roles stay yours to assign.
324
- Apps and users name the session (`session`); foreign and unknown sessions are both `404 not_found`. MCP: `name`,
378
+ Apps and users name the session (`session`); foreign and unknown sessions are both `404 not_found`. A session token
379
+ writes for its own session only (another one is `403 forbidden`), so an agent names itself and claims or releases its
380
+ own roles with `hub name` / `hub role claim|release` from its shell, the OpenCode `hub` tool (verbs `name`,
381
+ `role_claim`, `role_release`; no `session` argument) or, in Claude Code, the plugin's MCP tools. MCP: `name`,
325
382
  `role_claim` and `role_release`. Each change is one `session.named`, `role.claimed` or `role.released` event.
326
383
 
384
+ **Intent.** Each session has an optional intent: one line (at most 280 characters) saying what it is for. It is
385
+ untrusted text, sanitised and never interpreted. `GET /api/agent/sessions/:addr/intent` answers `{intent}`: the
386
+ stored one, else the session's non-placeholder title (`source: "title"`), else `null` (users, `sessions:read`,
387
+ session tokens). `PUT /api/agent/sessions/:addr/intent {text | null, expectedRevision}` sets or clears it: users
388
+ and apps with `sessions:manage` write `source: "user"`, a session token its own session only (`source: "self"`;
389
+ another is `403 forbidden`). A launch's `intent` is stored as `source: "launch"`, and the first prompt the hub
390
+ carries (console, launch or acting) becomes `source: "prompt"` once. Precedence is user > launch > self > prompt >
391
+ title; `null` falls back to the next one. A stale `expectedRevision` is `409 conflict {currentRevision}`. Stored
392
+ intents also appear as `intent {text, source, revision}` in `GET /sessions`, `GET /sessions/:addr` and `who`.
393
+ Changes are `session.intent {source, revision}` events, never the text. CLI `hub intent <addr> [text… | --clear]`,
394
+ MCP `session_intent_get` / `session_intent_set`, SDK `intent(addr)` / `setIntent(addr, text, expectedRevision)`;
395
+ the console shows it in the session's Details rail, with Edit (live: the console stream carries
396
+ `{type: "intent", sessionID, source, revision}`, never the text). Reads fetch only the listed sessions' intents.
397
+ Retention: every 6 h one hub drops the intents of deleted sessions and of sessions neither seen nor written for 30
398
+ days; an online session's intent is always kept. A dropped intent's revision starts over at 0.
399
+
327
400
  **Events.** `GET /api/agent/events?cursor=&wait=&limit=&type=&session=` reads a bounded per-user journal (24 h or
328
401
  10,000 events) of compact events: `session.added/removed/changed`, `blocker.opened/closed`, `machine.changed`,
329
- `message.changed`, `launch.changed`, `session.archived`, `session.named`, `role.claimed`, `role.released`. Each is `{id, at, type, subject, data}` with IDs and states
402
+ `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
330
403
  only: never prompts, message bodies, titles, blocker summaries, launch reasons or labels. Without a cursor the answer
331
404
  is empty with a cursor at the latest event; pass the returned `cursor` back to get what came after. `wait` (0–60 s)
332
405
  holds the request while nothing matches; `limit` is 1–200 (default 50); `type` (exact, or `launch.*`) and `session`
@@ -389,7 +462,7 @@ cookie and a hub bearer token (400).
389
462
  |---|---|---|---|---|
390
463
  | `GET /api/agent/whoami` | ✓ | ✓ | ✓ (any scope) | `{actor, user}` |
391
464
  | `GET /api/agent/status` | ✓ | ✓ | ✓ | machines → projects → sessions, each with name, roles, state, harness, activity and blockers, plus `attention` |
392
- | `GET /api/agent/sessions?project=&state=&machine=&label=k=v` | ✓ | ✓ | ✓ | the same sessions as a flat list |
465
+ | `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) |
393
466
  | `GET /api/agent/sessions/:addr` | ✓ | ✓ | ✓ | one session with full blockers and the spoke's summary (`unsupported` when its harness lacks it) |
394
467
  | `GET /api/agent/sessions/:addr/log?n=20` | ✓ | ✓ | ✓ | newest timeline rows (1–200); `422 unsupported {feature, harness}` when the spoke has no timeline, `503 offline` |
395
468
  | `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 |
@@ -407,6 +480,11 @@ cookie and a hub bearer token (400).
407
480
  | `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
408
481
  | `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
409
482
 
483
+ Sends to a session (`msg`, `ask`, `reply`, `decline`) answer `{message, delivery, woke, wakeSkipped?}`: `woke` says
484
+ whether the hub sent it to wake the recipient, and when not, `wakeSkipped` says why (`wake_false`, `subagent`,
485
+ `hop_cap`, `budget`; see **Limits**). The CLI and MCP tools print it, e.g. `queued (read at its next turn) — wake
486
+ skipped: hop limit reached`. Hubs before 0.8.1 send neither field.
487
+
410
488
  The act verbs, grants, kill switches and the agent audit are in [Agent actions and grants](#agent-actions-and-grants).
411
489
 
412
490
  **Decisions and `@me`.** `@me` addresses the user: `msg @me` is stored in the user's inbox (push kind `message`);
@@ -458,9 +536,12 @@ most 10,000 characters (a decline's reason 1,000).
458
536
 
459
537
  **Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
460
538
  OpenCode's synthetic message; it shows in the console's conversation as a message row). An idle session gets a
461
- turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it; subagent sessions and
462
- `--no-wake` messages wait for the next turn. A session that is offline gets it when it comes back (pending, for 24
463
- hours). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
539
+ turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it. A `--no-wake` message never
540
+ gets a model call of its own: an idle session reads it with its next turn; a busy one gets it once the running turn
541
+ ends (until then the hub keeps it `pending`), so it too arrives with the next turn. Urgent `--no-wake` messages,
542
+ replies and asks to a busy session that is not woken (a subagent, past the hop cap or wake budget) go into the running
543
+ turn at once, which may cost that turn one more model call. Subagent sessions are never woken. A session that is
544
+ offline gets it when it comes back (pending, for 24 hours; after that it expires undelivered, `expired`, and its sender is told: a session by a quiet hub notice, you by a notification). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
464
545
  `unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
465
546
  after waits of 5, 15 and 45 minutes in turn, then you are notified. Messages to you and to apps are stored in an inbox (`hub inbox`).
466
547
 
@@ -491,7 +572,8 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
491
572
  **Where agents send from.**
492
573
  - In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
493
574
  - The model itself: the plugin's `hub` tool (verbs `msg`, `ask`, `reply`, `decline`, `inbox`, `who`, `status`,
494
- `whoami`, and the [act verbs](#agent-actions-and-grants)), as the calling session ([Spoke](#spoke-opencode-plugin)).
575
+ `whoami`, the [team intake](docs/teams-intake.md) verbs `intake` and `intake_update`, `name`, `role_claim` and
576
+ `role_release` for its own @name and roles, and the [act verbs](#agent-actions-and-grants)), as the calling session ([Spoke](#spoke-opencode-plugin)).
495
577
  - You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
496
578
  - Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with, or the hub's
497
579
  [`POST /mcp`](#over-http-mcp) as the bearer token sent.
@@ -499,8 +581,21 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
499
581
 
500
582
  **Limits.** Per 10 minutes: 20 messages per sender→recipient pair and 300 per user (`rate_limited`, exit 6, with
501
583
  `retryAfter`), and 30 turns started per recipient (more messages are still delivered, without waking it). A chain of
502
- replies between agents may run 8 hops without a human turn; past that, messages are delivered without waking and
503
- you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. You can switch
584
+ messages between sessions may run 8 hops without a human turn; past that, messages are delivered without waking and
585
+ you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. Apps (an app
586
+ token, and the hub's own Concierge, workspace coordinator and team intake) count hops per conversation: a new `msg`
587
+ or `ask` from an app starts at hop 1 and replies to an app do not count against it, so an app's 5th (or 50th) ask
588
+ still wakes the session it talks to. Two guards keep apps from restarting chains: a reply or decline counts up from
589
+ the message it answers (reply ping-pong through an app is capped like any other chain), and for 5 minutes after a
590
+ session sends an app something, the app's new messages to *other* sessions continue from that hop (a relay A → app →
591
+ B → app → A reaches the cap after about 8 hops; spaced-out work starts at hop 1 again). An app answering one session
592
+ with new messages forever is held by the pair limit and the wake budget. A team intake submitted by a session
593
+ continues that session's chain. A send's answer says when and why it did not wake (`woke`, `wakeSkipped`); the CLI,
594
+ `hub mcp` and the OpenCode `hub` tool print it.
595
+
596
+ **Never give agents an app token.** A session that holds one (in its environment, an MCP config, a gateway) can send
597
+ as the app, at a fresh hop depth, outside its own chain and its session-scoped policy. Sessions message with their
598
+ own session token (the `hub` tool, `hub msg` in their shell); app tokens are for scripts and outside tools. You can switch
504
599
  messaging off for your account (`PUT /api/agent/messaging {enabled: false}`): sends then answer `paused` (exit 5),
505
600
  reading still works.
506
601
 
@@ -581,6 +676,7 @@ allow patterns (else `needs_user`) → the hourly cap → the kill switches agai
581
676
  | `gone` | 410 | 8 | answered elsewhere first, expired or ended |
582
677
  | `unsupported` | 422 | 7 | the session's spoke cannot do it (e.g. an agent's prompt without `hubPrompt`) |
583
678
  | `offline` | 503 | 7 | no spoke reports the session now |
679
+ | `busy` | 503 | 7 | the hub's state did not answer in time (`retryAfter`): refused, try again |
584
680
 
585
681
  **Escalation.** An approval outside the grant answers `needs_user` with the evaluation (`verdict`, `reason`, the
586
682
  grants checked, and a `tier`) and opens a Decision for you: one per agent and request (asking again updates its
@@ -633,7 +729,7 @@ and the SDK. Only you manage grants: the console (Settings → Agents) or the CL
633
729
  | `GET` · `PUT /api/admin/agents/acting {enabled}` | admins | – | – |
634
730
  | `GET /api/agent/audit?limit=&before=` | ✓ | – | – |
635
731
 
636
- **Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`) is in
732
+ **Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`, `busy`) is in
637
733
  your agent audit with who acted, the verb, the session and request, the grant, the reason and the outcome (`ok`,
638
734
  `gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`); so are your answers to escalations, grant
639
735
  requests and switch changes. Read it in Settings → Agents → Activity or with `GET /api/agent/audit` (newest first,
@@ -669,11 +765,17 @@ those harnesses through their service spokes ([docs/launches.md](docs/launches.m
669
765
  spec's fields and limits to placement, caps, the node's git rules, webhook signatures and sandboxed launches in
670
766
  OpenShell (`hub node openshell check`): [docs/launches.md](docs/launches.md).
671
767
 
768
+ A queued launch says why it waits (`hub launches list`, `GET /api/agent/launches/:id` `waiting`, Settings → Agents &
769
+ access → Launches): the per-user cap `agents.launches.maxActive` (default 4) with who holds it, a full node with its
770
+ used/reserved slots, or each node's first problem. The cap counts every launcher's live launches (apps, sessions,
771
+ you), except the Concierge's own; revoking an app token does not stop its launches, they are flagged so you can stop
772
+ them ([docs/launches.md](docs/launches.md#how-the-caps-count)).
773
+
672
774
  ## hub CLI
673
775
 
674
776
  `hub` (also installed as `hubctl`) reads, names, messages and acts on your sessions through the hub's [`/api/agent` API](#agent-identity-agent-api),
675
- for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`$HUB_STATE_DIR/bin`, default
676
- `~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
777
+ for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`bin` in
778
+ the hub state dir, see `HUB_STATE_DIR` under [Configuration](#configuration): usually `~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
677
779
  session `hub` just works. A launcher runs the CLI bundle shipped in this package (`packages/cli/dist/hub-cli.js`)
678
780
  with the OpenCode binary itself (`BUN_BE_BUN=1 exec <opencode> hub-cli.js "$@"`), so neither bun nor node has to
679
781
  be installed; without Bun it falls back to `node`. The launchers are rewritten only when their content changes.
@@ -681,7 +783,7 @@ When a standalone hubctl is installed as `~/.local/bin/hubctl` (what `hubctl set
681
783
  instead, so shells, services and MCP clients all use that one signed program. For your own terminals the plugin also
682
784
  links `~/.local/bin/hub` to its `hub` launcher, with the setup's rule for `~/.local/bin`: only when nothing is there or
683
785
  it is hub's own link, never when another `hub` is on PATH (then it prints where hub's is), and only from the default
684
- state dir (not with `HUB_STATE_DIR`). `hubctl setup`'s `hub` → `hubctl` link is kept; `~/.local/bin/hubctl` is left to
786
+ state dir (never with `HUB_STATE_DIR`). `hubctl setup`'s `hub` → `hubctl` link is kept; `~/.local/bin/hubctl` is left to
685
787
  `hubctl setup`. When `~/.local/bin` is not on PATH it prints one hint.
686
788
 
687
789
  **Name clash:** GitHub's old `hub` CLI (github/hub) uses the same name. In hub sessions ours comes first on PATH;
@@ -696,6 +798,7 @@ hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pin
696
798
  hub whoami who this credential is
697
799
  hub who [--project P] [--role R | @@role] named and role-holding sessions
698
800
  hub name <@name> [--session <addr>]
801
+ hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters)
699
802
  hub role claim <role[.project]> [--exclusive] [--session <addr>]
700
803
  hub role release <role[.project]> [--session <addr>]
701
804
  hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
@@ -743,7 +846,8 @@ unchanged (for errors, the `{error: {code, message, hint?, …}}` envelope on st
743
846
  message, any candidates or retry time, a missing feature, and the hint on stderr.
744
847
 
745
848
  **Messaging.** `msg` and `ask` print what was stored and how it reached the recipient (`delivered` into a new or
746
- the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline, or
849
+ the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline (or,
850
+ for a `--no-wake` message, while the recipient's turn runs), or
747
851
  `stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
748
852
  `--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
749
853
  read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
@@ -796,7 +900,16 @@ timestamps older than 5 minutes refused). The spec's fields and limits, states,
796
900
  **hub node.** `hub node run` is the per-machine daemon that prepares launch workspaces: it needs an enrolled
797
901
  machine (it only reads the machine token in `~/.config/hub/config.json`) and `~/.config/hub/node.json` with at
798
902
  least `{"workspaceRoots": ["/Users/you/projects"]}` (optional `labels`, `slots`, `memoryMB`); it exits 1 when the
799
- machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. `hub node install` writes a
903
+ machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. While it offers the
904
+ opencode harness (the default; node.json `"harnesses": {"opencode": false}` withholds it) it keeps OpenCode's
905
+ background service warm: every 30 s it finds the service through its registration file
906
+ (`$XDG_STATE_HOME/opencode/service.json`, default `~/.local/state/opencode/service.json`, read directly: no request
907
+ before the check) and, only for a literal loopback URL without user info (127.0.0.1, [::1]; localhost is asked as
908
+ 127.0.0.1), reads `GET /api/agent` (the service's default location; redirects are never followed) so the hub plugin loads and the machine comes online without
909
+ anyone opening a project (logged when it starts working or fails). It also loads the projects of the service's
910
+ recent sessions under `workspaceRoots` (at most 8, or node.json `opencode.directories`), and warms each foreground
911
+ server listed in node.json `opencode.servers` (`{url, passwordFile, directories?}`: a loopback URL and a `chmod 600`
912
+ file holding its password; see [docs/launches.md](docs/launches.md#hub-node)) the same way. `hub node install` writes a
800
913
  launchd agent (macOS, `~/Library/LaunchAgents/io.github.huynle.hub-node.plist`) or a systemd user unit (Linux,
801
914
  `~/.config/systemd/user/hub-node.service`) from the templates in `scripts/`, with the runtime and CLI entry running
802
915
  the command (so `hubctl node install` from a hub session runs the bundle with the OpenCode binary) and
@@ -836,7 +949,7 @@ is set and the credential is not that session. The CLI removes `BUN_BE_BUN` from
836
949
  | 4 | `needs_user` |
837
950
  | 5 | `forbidden`, `paused` |
838
951
  | 6 | `rate_limited` (with `retryAfter`) |
839
- | 7 | `unsupported`, `offline` |
952
+ | 7 | `unsupported`, `offline`, `busy` |
840
953
  | 8 | `gone`; an `ask --wait` that expired |
841
954
  | 9 | `unauthorized`, `token_expired` (a session token: start a new shell), or not logged in |
842
955
 
@@ -856,7 +969,10 @@ carry the same canonical compact events as `events`; their signing secret appear
856
969
  `answer` (`answer` object or `cancel`), `prompt` (`delivery`) and `stop`, and the launch tools `launch` (`repo`
857
970
  required and absolute; `mode`, `branch`, `base`, `remote`, `key`, `name`, `roles`, `harness`, `agent`, `model`,
858
971
  `runtime`, `profile`, `machine`, `nodeLabels` and `labels` objects, `prefer`, `prompt`, `end`, `timeout`,
859
- `cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`), with the same arguments
972
+ `cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`); the profile tools (`profiles`,
973
+ `profile_show`, `profile_create`, `profile_version_create`, `profile_skill_set`, `profile_command_set`,
974
+ `profile_assign`, …; [docs/profiles.md](docs/profiles.md#sdk-and-mcp)), the workspace, artifact and team tools
975
+ ([docs/teams.md](docs/teams.md#mcp-tools); `team_staff` takes the nested launch spec), with the same arguments
860
976
  (`to`, `text`, `addr`, `id`, `requestID`, `formID`, `why`, `n`, `wake`, `urgent`, `all`, `project`, `role`, …). The act
861
977
  tools need an app token with scope `sessions:act` and a grant, the launch tools scope `sessions:launch` and a
862
978
  launch grant; `needs_user` results include the evaluation. `ask` takes `wait` of at
@@ -1050,8 +1166,11 @@ not persist those potentially sensitive details.
1050
1166
  keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
1051
1167
  interrupt the root turn at once.
1052
1168
  - **Messaging other sessions**: the plugin adds a tool `hub {verb, …}` with the verbs `msg`, `ask`, `reply`,
1053
- `decline`, `inbox`, `who`, `status` and `whoami` (`src/spoke/hub-tool.ts`). It acts as the calling session
1054
- with that session's own hub token (the token never appears in the tool's output). `ask` with `wait` (seconds,
1169
+ `decline`, `inbox`, `who`, `status`, `whoami`, `intake` and `intake_update` (`src/spoke/hub-tool.ts`). It acts as the calling session
1170
+ with that session's own hub token (the token never appears in the tool's output). `name {name}` sets the
1171
+ session's own @name (the old one stays an alias for 24 h; `name_taken`, invalid and reserved names fail as in
1172
+ `hub name`), `role_claim {role, project?, exclusive?}` and `role_release {role, project?}` its own roles; these
1173
+ take no `session`: a session changes only itself. `ask` with `wait` (seconds,
1055
1174
  at most 600) blocks for the answer until the tool call is stopped; without it the answer arrives later as a hub
1056
1175
  message. Messages from other sessions are shown in the hub's envelope and are not from the user.
1057
1176
  - Child text and execution events are paired by the hub. The latest assistant message of a successful
@@ -1089,10 +1208,11 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
1089
1208
  | `HUB_AUTH` | `pairing` | `oidc` for several users signing in with Google; see [Multi-user](#multi-user-sign-in-with-google) |
1090
1209
  | `HUB_HOST` / `HUB_PORT` | `127.0.0.1` / `4097` | listen address |
1091
1210
  | `HUB_PUBLIC_URL` | `http://<host>:<port>` (`http://127.0.0.1:<port>` when the host is `0.0.0.0` / `::`) | origin used in links, cookies and CSRF checks |
1092
- | `HUB_STATE_DIR` | `~/.local/state/hub` | `state.json` (0600): users, machines and devices (token hashes only) with their owners, VAPID keys, push subscriptions, speech settings (server default and per user; provider API keys set from the phone), the audit log (last 500 entries); `token-keys.json` (0600): the HMAC keys that sign session tokens (created on first use, older keys kept so their tokens still verify); `agent/` (0700, files 0600, created on first use): agent data such as messages and name claims, one JSON file per user and collection, written shortly after each change and on shutdown |
1211
+ | `HUB_STATE_DIR` | `$XDG_STATE_HOME/hub` if it holds hub state (`state.json`, `token-keys.json`, `agent/`, `bin/`, … — a `bootstrap/` alone does not count), else an existing `~/.local/state/hub` holding hub state (kept after an upgrade: the hub logs the `mv` to switch), else `$XDG_STATE_HOME/hub` when XDG_STATE_HOME is set (fresh installs), else `~/.local/state/hub`; the plugin, the local hub, the hub server and the Claude Code and Codex spokes all resolve it the same way | `state.json` (0600): users, machines and devices (token hashes only) with their owners, VAPID keys, push subscriptions, speech settings (server default and per user; provider API keys set from the phone), the audit log (last 500 entries); `token-keys.json` (0600): the HMAC keys that sign session tokens (created on first use, older keys kept so their tokens still verify); `agent/` (0700, files 0600, created on first use): agent data such as messages and name claims, one JSON file per user and collection, written shortly after each change and on shutdown |
1093
1212
  | `HUB_TOKEN_KEYS` | | overrides `token-keys.json` with the same JSON, `{"active": "<kid>", "keys": [{"kid": "<kid>", "secret": "<base64 of ≥ 32 random bytes>", "created": <ms>}]}`, e.g. a secret shared by several hub instances; the file is then never written |
1094
1213
  | `HUB_DATABASE_URL` / `HUB_VALKEY_URL` | | cluster mode: several hub pods sharing Postgres and Valkey (both or neither; requires `HUB_AUTH=oidc` and `HUB_TOKEN_KEYS`); see [docs/cluster.md](docs/cluster.md) |
1095
1214
  | `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
1215
+ | `HUB_DATABASE_STATEMENT_TIMEOUT_MS` | `30000` | cluster mode: Postgres `statement_timeout` of the hub's pools (`0` turns it off) |
1096
1216
  | `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
1097
1217
  | `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
1098
1218
  | `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
@@ -1116,9 +1236,30 @@ recent activity.
1116
1236
  the Docker image, npm package and binaries ship the build). One store fed by the hub's SSE stream drives:
1117
1237
 
1118
1238
  - **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
1119
- terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
1239
+ terminal grouped by project (or server) with `+`, Recents. Each section collapses from its heading (remembered on
1240
+ 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
1120
1241
  title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
1121
1242
  (rename, pin, archive, stop, Allow/Disallow Voice).
1243
+ - **Primary navigation** above the session groups: **All sessions**, **Artifacts** (the library across workspaces,
1244
+ [docs/artifacts.md](docs/artifacts.md)), **Watchtower** ([docs/watchtower.md](docs/watchtower.md)) and **Agent
1245
+ profiles** ([docs/profiles.md](docs/profiles.md)). The sidebar's **Workspaces** tree (drag to supervise) and the
1246
+ session ⋯ menu's **Start side chat…** are described in [docs/workspaces.md](docs/workspaces.md).
1247
+ - **Split panes**: **Open beside** in a session's `⋯` menu (table, sidebar, phone list) opens it next to the
1248
+ focused pane, up to 4 panes, side by side, stacked or in a grid (the icon switch in the bar above the panes).
1249
+ On desktop you can also **drag** a session row (sidebar or Workspaces tree), an artifact (Artifacts library) or
1250
+ the Watchtower detail's **Drag into a pane** handle onto a pane: an edge splits there (left/right side by side,
1251
+ top/bottom stacked), the centre replaces that pane, and the shape reflows (a pane dropped across a side-by-side
1252
+ pair makes a grid). Off the pane view a small map of your panes appears to drop on. Drag a pane's grip (⠿ in
1253
+ its header) to move it the same way. A session already open moves instead of opening twice; a fifth pane is
1254
+ refused with a notice. The `⋯` menu's **Open in pane → left/right/top/bottom** does the same from the keyboard
1255
+ (beside the focused pane). Drags carry no session ID or link outside the console. The focused pane
1256
+ (accent line) owns the composer, Dictate and Voice; the URL is its session, and following a link replaces
1257
+ it. Click a pane, or press F6 / Shift+F6 or Ctrl+Alt+1…4, to focus one; Ctrl+Alt+Shift+arrows (or the pane's
1258
+ arrows) move it; Ctrl+Alt+W or ✕ closes it. Closing a pane never stops its session, and a pane whose session
1259
+ vanishes closes with a notice. Drag the separators, or focus one and use the arrows (16px, Shift 64px),
1260
+ Home/End; double-click evens them. Which panes are open is kept per device (`hub.paneLayout.v1`), their sizes
1261
+ too (`hub.paneSizes.v1`); nothing is sent to the hub. On a phone the panes are tabs (arrow keys between
1262
+ tabs); only the selected one is on screen and streams live text.
1122
1263
  - **Archive**: **Archive** in a session's `⋯` menu takes it out of the table, the sidebar's groups, Recents and
1123
1264
  Running, on all your devices (with **Undo**). Nothing is deleted and OpenCode's history is untouched:
1124
1265
  archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive
@@ -1218,12 +1359,17 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
1218
1359
  a task or ask a question" (images can be attached), and under it where it runs: **Environment** (your
1219
1360
  machines on this hub: online first with their process and OpenCode version, offline ones listed but not
1220
1361
  selectable; ✓ on the chosen one), **Project** (a popover with search: Herdr workspaces with their pane
1221
- folders, then known folders), the folder's **branch** (from the summary of the latest session in that
1362
+ folders, then known folders, with full paths; the chosen folder's full path shows under the chips; switching
1363
+ the environment never carries a folder over, not even a path both machines report: the chip says "Choose a
1364
+ project" until you pick one of that machine's own), the folder's **branch** (from the summary of the latest session in that
1222
1365
  folder, when there is one) and **Open in Herdr tab** (a Herdr workspace on a machine that runs Herdr; on
1223
1366
  by default). Submitting sends `POST /api/machines/:id/sessions {directory, herdrWorkspace?}`, opens the
1224
1367
  new session and sends the prompt (and images) as its first turn (`POST /api/sessions/:id/turn`). The last
1225
- environment and folder are remembered on the device (`localStorage` `hub.newSession`); the group "+"
1226
- preselects with `?machine=&key=&dir=`. "Opened in Herdr · <workspace>" confirms the new tab; if Herdr
1368
+ environment and folder are remembered on the device (`localStorage` `hub.newSession`); the remembered folder is
1369
+ preselected only while that machine still reports exactly that folder, and nothing else is guessed: otherwise the
1370
+ chip says "Choose a project", and the send arrow stays grey (disabled, `aria-disabled`) until a project is chosen
1371
+ (CON-12). A machine that reports exactly one project has it chosen; Enter without a project says so and moves the
1372
+ focus to the Project chip (FX-06). The group "+" preselects with `?machine=&key=&dir=`. "Opened in Herdr · <workspace>" confirms the new tab; if Herdr
1227
1373
  failed, the session still exists and the hub's `warning` is shown. 409 (background service not
1228
1374
  connected) is shown under the composer.
1229
1375
 
@@ -1720,7 +1866,7 @@ in the file).
1720
1866
  | `auth` | `{mode, issuer?, clientId, clientSecret, allowed, admins}`; see [Multi-user](#multi-user-sign-in-with-google) |
1721
1867
  | `cluster` | `{database, valkey}` URLs for cluster mode (env `HUB_DATABASE_URL` / `HUB_VALKEY_URL` win); see [docs/cluster.md](docs/cluster.md) |
1722
1868
  | `agents.never` | permission patterns (`action[:glob]`) no agent may ever approve, for every user, checked before any grant; see [Agent actions and grants](#agent-actions-and-grants) |
1723
- | `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60); see [docs/launches.md](docs/launches.md#placement-and-caps) |
1869
+ | `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) |
1724
1870
  | `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) |
1725
1871
 
1726
1872
  Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
@@ -1760,8 +1906,11 @@ When the configured server is loopback (`localhost`, `127.0.0.1`, `[::1]`, http
1760
1906
  on it, the plugin starts `<this repo>/bin/hub.ts` as a detached background process bound to that
1761
1907
  address and port. The runtime (`resolveHubRuntime` in `src/spoke/localhub.ts`): this process when it is
1762
1908
  `bun`, else `bun` on `PATH`, else `~/.bun/bin/bun`, else the current executable with `BUN_BE_BUN=1`
1763
- (inside OpenCode that is the `opencode` binary, a compiled Bun, so nothing else needs installing). Output is appended to `~/.local/state/hub/hub.log` (`$HUB_STATE_DIR`
1764
- if set; a legacy `~/.local/state/opencode-hub`, else `~/.local/state/opencode-voice`, is copied there first). It happens on setup (**On this
1909
+ (inside OpenCode that is the `opencode` binary, a compiled Bun, so nothing else needs installing). Output is appended to `hub.log` in the hub state dir (`$HUB_STATE_DIR`, else as in the
1910
+ `HUB_STATE_DIR` row of the configuration table, usually `~/.local/state/hub`). When that dir holds no hub state yet (missing,
1911
+ empty, or only `hubctl setup`'s `bootstrap/`), the newest legacy state dir is copied there first (next to
1912
+ `bootstrap/`, never over it): `opencode-hub`, then `opencode-voice`, under `$XDG_STATE_HOME` and
1913
+ then under `~/.local/state` (never with `HUB_STATE_DIR`). It happens on setup (**On this
1765
1914
  computer**, any process), on **Open hub** (even with auto-start
1766
1915
  off), and when an enrolled connection is refused:
1767
1916
  the shared service process (`--service`) tries at once, other processes after 8 s so the service goes
@@ -1873,8 +2022,12 @@ npm run build:cli # packages/cli/dist/hub-cli.js, the hub CLI bundle (prepack
1873
2022
 
1874
2023
  Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
1875
2024
  `npm run dev:web` serves the console with hot reload, proxying `/api` to it (`HUB_URL` for a real hub).
1876
- `npm run test:e2e` runs a headless Chromium smoke test against the mock hub at desktop and phone sizes
1877
- (`CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to the temp dir).
2025
+ `npm run test:e2e` (scripts/test-e2e.sh) runs every headless Chromium suite, each alone: the smoke test against the
2026
+ mock hub at desktop and phone sizes (with the nav, profiles, workspace, Watchtower, team editor and artifact suites
2027
+ it chains), then the real-hub suites `web/e2e/artifact-host.ts`, `web/e2e/artifact-bridge.ts` and
2028
+ `web/e2e/hwa-workflow.ts` (two simulated machines: side chat launch and Send to main, a declared action from an
2029
+ embedded app). Build `web/dist` first; `CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to
2030
+ the temp dir. CI does not run them (no Chromium there): `scripts/test-ci.sh` runs the bun tests.
1878
2031
 
1879
2032
  `test/e2e-phase2.test.ts` does the same for messaging: the OpenCode spoke (with `deliver`) and a spoke without it,
1880
2033
  `hub msg`/`ask`/`mute` as session tokens, replies through the `hub` tool's code path, an app token with the
@@ -1900,7 +2053,9 @@ second user. The opt-in `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.tes
1900
2053
  `opencode serve` (own port, password and XDG dirs) with a local stub model and checks a real shell: its
1901
2054
  `HUB_SESSION_TOKEN`, and that `hub whoami` there prints the session. `HUB_IT_OPENCODE=1 bun test
1902
2055
  test/spoke-deliver.it.test.ts` does the same for hub messages: the model sees the delivered envelope, a repeated
1903
- message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin. `HUB_IT_OPENCODE=1 bun test test/spoke-hub-tool.it.test.ts` has the stub model call the `hub` tool and checks
2056
+ message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin.
2057
+ `HUB_IT_OPENCODE=1 bun test test/e2e-quiet-delivery.it.test.ts` sends `wake: false` messages through a real hub to an
2058
+ idle and to a busy session and checks that neither costs a model call and the next real turn carries them. `HUB_IT_OPENCODE=1 bun test test/spoke-hub-tool.it.test.ts` has the stub model call the `hub` tool and checks
1904
2059
  that the hub sees the session's token while the model and the transcript never do. `HUB_IT_OPENCODE=1 bun test
1905
2060
  test/spoke-prompt-refs.it.test.ts` checks that a prompt naming a session reaches the model unchanged with the
1906
2061
  reference note attached, and that hub-sourced prompts get none.
@@ -1914,6 +2069,13 @@ with a throwaway `HOME`, `CLAUDE_CONFIG_DIR` / `CODEX_HOME` and a deny-all proxy
1914
2069
  `bun packages/spoke-sdk/scaffold.ts <name> <dir>`, whose output passes `npx hub-conformance spoke --driver "bun
1915
2070
  <dir>/driver.ts"` as written ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
1916
2071
 
2072
+ `test/e2e-hwa-*.test.ts` (part of `npm test`) run workspaces, profiles, side chats, artifacts, AI jobs, teams and the
2073
+ Watchtower graph end to end across two simulated machines (`test/e2e-hwa-kit.ts`: each a node plus a service spoke,
2074
+ real launch engine): a workspace bound on both, supervision, board, digests and checkpoints, disconnect/reconnect,
2075
+ master replacement, archive and a restart that keeps everything; profile acks; jobs handled on the other machine;
2076
+ teams through `POST /mcp`; load caps; a record from a newer hub refused, never overwritten; and a tenant matrix
2077
+ that sends every parameterised route of these features as another user (answers like an unknown ID).
2078
+
1917
2079
  Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
1918
2080
  `{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
1919
2081
  OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
@@ -1944,6 +2106,42 @@ workflow). Publish it by hand after `@huynle/hub` of the same version is on npm:
1944
2106
  `@huynle/opencode-hub` was never published, so it has no shim. A new package name needs one manual first
1945
2107
  publish before trusted publishing can take over (see the comment in `release.yml`).
1946
2108
 
2109
+ ### Hub state dir: upgrades and service units
2110
+
2111
+ A dir counts as the hub state dir only when it holds hub state (`state.json`, `token-keys.json`, `agent/`, `blobs/`,
2112
+ `concierge-sessions.json`, `bin/`, `hub.log`, `hub.pid`, `claude-code/` or `codex/`); `hubctl setup`'s `bootstrap/`
2113
+ alone does not count, and setup writes `bootstrap/` into the dir the hub resolves. A relative `XDG_STATE_HOME` is
2114
+ ignored (the XDG spec allows only absolute paths). Nothing moves on upgrade: with
2115
+ `XDG_STATE_HOME` set and an existing `~/.local/state/hub`, the hub keeps that one and logs the `mv` command to switch.
2116
+
2117
+ **Service units must see the same `XDG_STATE_HOME` as your shell** (or set the same `HUB_STATE_DIR`): the resolver
2118
+ runs in every process, so a unit without `XDG_STATE_HOME` next to a shell that has it can pick a different dir on a
2119
+ fresh install (two `hub.pid` locks, two Concierge lock files, launchers in one dir and logs in the other). The units
2120
+ hub writes itself (`hub claude-code install`, `hub codex-spoke install`) already pass `XDG_STATE_HOME` and
2121
+ `HUB_STATE_DIR` through. A unit you write yourself, such as one running `opencode serve --service` (the OpenCode
2122
+ background service, which loads the hub plugin), must set `Environment=XDG_STATE_HOME=…` (systemd) or the plist's
2123
+ `EnvironmentVariables` (launchd) to your shell's value, or `HUB_STATE_DIR`.
2124
+
2125
+ ### Testing and preview isolation
2126
+
2127
+ A test or preview OpenCode with the hub plugin shares the user's files unless told otherwise: its spoke may
2128
+ auto-start a hub, write the `hub`/`hubctl` launchers, the Concierge lock file and `hub.log`, and read Herdr. Keep it
2129
+ apart:
2130
+
2131
+ - **State**: an isolated instance MUST set `HUB_STATE_DIR` to a scratch directory and its own `HOME` (plus its own
2132
+ `XDG_CONFIG_HOME` / `XDG_STATE_HOME` / `XDG_DATA_HOME` / `XDG_CACHE_HOME`). `XDG_STATE_HOME` alone is not enough:
2133
+ the state dir is sticky, so with the real `~/.local/state/hub` present it is used even when `XDG_STATE_HOME` points
2134
+ elsewhere. With `HUB_STATE_DIR` set the plugin never links `~/.local/bin/hub` and migrates nothing. With `HUB_STATE_DIR` set,
2135
+ the Concierge lock is read and written only there: an isolated instance never reads the user's
2136
+ `~/.local/state/hub` or `$XDG_STATE_HOME/hub`.
2137
+ - **Spoke config** (`config.json` in its own `XDG_CONFIG_HOME/hub`, or `HUB_SPOKE_CONFIG`): `"herdr": false` (no
2138
+ reading the user's Herdr panes), `"autoStartLocalHub": false` (no hub started on a loopback URL that is down), and
2139
+ the test hub's URL.
2140
+ - **Sessions**: create them only in scratch repositories (a temp dir with `git init`), never in a real project, so
2141
+ a stray session, launch or worktree cannot land in your work.
2142
+ - Never restart the user's `opencode serve --service`; start a private `opencode serve` on its own port and
2143
+ password (as the `*.it.test.ts` suites do).
2144
+
1947
2145
  ## Known limits
1948
2146
 
1949
2147
  - Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.7.4",
3
+ "version": "0.8.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",
@@ -99,7 +99,7 @@
99
99
  "packages/codex"
100
100
  ],
101
101
  "engines": {
102
- "bun": ">=1.3"
102
+ "bun": ">=1.4.4"
103
103
  },
104
104
  "publishConfig": {
105
105
  "access": "public"
@@ -110,7 +110,7 @@
110
110
  "build:web": "vite build --config web/vite.config.ts",
111
111
  "build:cli": "bun build packages/cli/src/main.ts --target=node --outfile=packages/cli/dist/hub-cli.js",
112
112
  "dev:web": "vite --config web/vite.config.ts",
113
- "test:e2e": "bun web/e2e/smoke.ts",
113
+ "test:e2e": "bash scripts/test-e2e.sh",
114
114
  "prepack": "npm run build:web && npm run build:cli",
115
115
  "hub": "bun bin/hub.ts"
116
116
  },
@@ -121,7 +121,9 @@
121
121
  },
122
122
  "devDependencies": {
123
123
  "@types/bun": "^1.4.2",
124
+ "@types/three": "0.186.0",
124
125
  "@types/web-push": "^3.6.4",
126
+ "3d-force-graph": "1.80.1",
125
127
  "onnxruntime-web": "1.23.2",
126
128
  "playwright-core": "^1.64.1",
127
129
  "solid-js": "^1.9.17",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-claude-code",
3
- "version": "0.7.4",
3
+ "version": "0.8.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",
@@ -9,7 +9,7 @@
9
9
  // once and runs its claude headless (launcher.ts) with the hub plugin, so its hooks come back here like any other
10
10
  // session's. turn and interrupt reach only those sessions (stdin user messages, control requests); a terminal
11
11
  // session refuses them. stop interrupts a launched session's running turn at once, besides the hook-level stop.
12
- // Stopping the daemon ends the claude processes it started.
12
+ // Stopping the daemon ends the claude processes it started. profileApply (HWA-17, profile.ts) is served either way.
13
13
  //
14
14
  // A `hub claude` run registers its pid ("launch"); the daemon checks it now and then and ends the run's sessions
15
15
  // when the process is gone (Claude Code killed without SessionEnd), and at once when the wrapper reports its exit.
@@ -23,6 +23,7 @@ import { parseHookInput } from "./hook-input.ts"
23
23
  import { ensurePrivateDir, SOCKET_PATH_MAX } from "./paths.ts"
24
24
  import { createMcpRouter, MCP_END, type McpRouter } from "./mcp.ts"
25
25
  import { ClaudeLauncher, launchAgent, launchDirectory, launchModel } from "./launcher.ts"
26
+ import { ClaudeProfiles } from "./profile.ts"
26
27
  import { hubReferences } from "./references.ts"
27
28
  import { ClaudeSessions, type SessionsOptions } from "./sessions.ts"
28
29
  import { ask, onLines, WIRE_VERSION } from "./wire.ts"
@@ -167,9 +168,14 @@ export async function startDaemon(options: DaemonOptions): Promise<ClaudeDaemon>
167
168
  return { stopped: stopped.stopped.length ? stopped.stopped : [req.sessionID] }
168
169
  }
169
170
 
171
+ const profiles = new ClaudeProfiles({
172
+ kind: (id, native = sessions.nativeOf(id)) => (native === undefined ? undefined : launchKeys.has(native) ? "launched" : "terminal"),
173
+ setModel: (id, model) => launcher!.setModel(sessions.nativeOf(id)!, model),
174
+ })
170
175
  const handlers: OpHandlers = {
171
176
  permission: (req) => sessions.permission(req),
172
177
  stop,
178
+ profileApply: profiles.handle,
173
179
  ...(launcher ? { create, turn, interrupt } : {}),
174
180
  }
175
181
  const features: ModifierFeature[] = ["permissionMessage", "hubPrompt", ...(launcher ? (["launch"] as const) : [])]
@@ -195,6 +201,7 @@ export async function startDaemon(options: DaemonOptions): Promise<ClaudeDaemon>
195
201
  ...(options.tokenWaitMs !== undefined ? { tokenWaitMs: options.tokenWaitMs } : {}),
196
202
  ...(options.permissionWaitMs !== undefined ? { permissionWaitMs: options.permissionWaitMs } : {}),
197
203
  references: options.references ?? hubReferences((id) => spoke.sessionToken(id)),
204
+ profiles,
198
205
  })
199
206
 
200
207
  const launches = new Map<string, number>()