@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  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/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. 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
@@ -145,12 +188,17 @@ shell, and the hub never gets a way to run commands here.
145
188
  # 1. Download hubctl for your platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64) and the signed manifest
146
189
  # from the release, then install the binary yourself (the first apply installs the release's copy as
147
190
  # ~/.local/bin/hubctl, with `hub` linked to it):
148
- curl -fsSLo hubctl https://github.com/huynle/hub/releases/download/v<version>/hubctl-darwin-arm64 && chmod +x hubctl
149
- curl -fsSLO https://github.com/huynle/hub/releases/download/v<version>/hubctl-manifest.json
191
+ curl -fsSLo hubctl https://github.com/huynle/hub-releases/releases/download/v<version>/hubctl-darwin-arm64 && chmod +x hubctl
192
+ curl -fsSLO https://github.com/huynle/hub-releases/releases/download/v<version>/hubctl-manifest.json
150
193
 
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
@@ -209,7 +257,7 @@ The hub serves the web app, pairs devices, enrolls machines, and does push and (
209
257
  | Way | For | How |
210
258
  |---|---|---|
211
259
  | **`/hub` → On this computer** | Local use; nothing to install (plugin from npm) | started by the plugin with OpenCode's own runtime (`BUN_BE_BUN=1 opencode bin/hub.ts`), or `bun` when installed |
212
- | **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub/releases) |
260
+ | **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub-releases/releases) (the source repository is private; releases are mirrored to the public `huynle/hub-releases`) |
213
261
  | **Docker** | Servers | `ghcr.io/huynle/hub` (linux/amd64, linux/arm64) |
214
262
  | **bunx / from source** | Bun users, development | `bunx --package @huynle/hub hub-server` · `bun bin/hub.ts` |
215
263
  | **Kubernetes (cluster mode)** | Several pods on Postgres + Valkey, rolling updates | [docs/cluster.md](docs/cluster.md), manifests in [`deploy/k8s/`](deploy/k8s/) |
@@ -221,8 +269,8 @@ answers `--help` and `--version`. While no device is paired, the hub logs a pair
221
269
  `hub-<darwin|linux>-<arm64|x64>` and `hub-windows-x64.exe`, plus `SHA256SUMS`.
222
270
 
223
271
  ```sh
224
- curl -LO https://github.com/huynle/hub/releases/latest/download/hub-linux-x64
225
- curl -LO https://github.com/huynle/hub/releases/latest/download/SHA256SUMS
272
+ curl -LO https://github.com/huynle/hub-releases/releases/latest/download/hub-linux-x64
273
+ curl -LO https://github.com/huynle/hub-releases/releases/latest/download/SHA256SUMS
226
274
  sha256sum --check --ignore-missing SHA256SUMS && chmod +x hub-linux-x64
227
275
  HUB_PUBLIC_URL=https://hub.example.com ./hub-linux-x64
228
276
  ```
@@ -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 |
@@ -458,9 +531,12 @@ most 10,000 characters (a decline's reason 1,000).
458
531
 
459
532
  **Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
460
533
  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
534
+ turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it. A `--no-wake` message never
535
+ 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
536
+ ends (until then the hub keeps it `pending`), so it too arrives with the next turn. Urgent `--no-wake` messages,
537
+ replies and asks to a busy session that is not woken (a subagent, past the hop cap or wake budget) go into the running
538
+ turn at once, which may cost that turn one more model call. Subagent sessions are never woken. A session that is
539
+ offline gets it when it comes back (pending, for 24 hours). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
464
540
  `unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
465
541
  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
542
 
@@ -491,7 +567,8 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
491
567
  **Where agents send from.**
492
568
  - In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
493
569
  - 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)).
570
+ `whoami`, the [team intake](docs/teams-intake.md) verbs `intake` and `intake_update`, `name`, `role_claim` and
571
+ `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
572
  - You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
496
573
  - Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with, or the hub's
497
574
  [`POST /mcp`](#over-http-mcp) as the bearer token sent.
@@ -696,6 +773,7 @@ hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pin
696
773
  hub whoami who this credential is
697
774
  hub who [--project P] [--role R | @@role] named and role-holding sessions
698
775
  hub name <@name> [--session <addr>]
776
+ hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters)
699
777
  hub role claim <role[.project]> [--exclusive] [--session <addr>]
700
778
  hub role release <role[.project]> [--session <addr>]
701
779
  hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
@@ -743,7 +821,8 @@ unchanged (for errors, the `{error: {code, message, hint?, …}}` envelope on st
743
821
  message, any candidates or retry time, a missing feature, and the hint on stderr.
744
822
 
745
823
  **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
824
+ the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline (or,
825
+ for a `--no-wake` message, while the recipient's turn runs), or
747
826
  `stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
748
827
  `--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
749
828
  read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
@@ -796,7 +875,16 @@ timestamps older than 5 minutes refused). The spec's fields and limits, states,
796
875
  **hub node.** `hub node run` is the per-machine daemon that prepares launch workspaces: it needs an enrolled
797
876
  machine (it only reads the machine token in `~/.config/hub/config.json`) and `~/.config/hub/node.json` with at
798
877
  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
878
+ machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. While it offers the
879
+ opencode harness (the default; node.json `"harnesses": {"opencode": false}` withholds it) it keeps OpenCode's
880
+ background service warm: every 30 s it finds the service through its registration file
881
+ (`$XDG_STATE_HOME/opencode/service.json`, default `~/.local/state/opencode/service.json`, read directly: no request
882
+ before the check) and, only for a literal loopback URL without user info (127.0.0.1, [::1]; localhost is asked as
883
+ 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
884
+ anyone opening a project (logged when it starts working or fails). It also loads the projects of the service's
885
+ recent sessions under `workspaceRoots` (at most 8, or node.json `opencode.directories`), and warms each foreground
886
+ server listed in node.json `opencode.servers` (`{url, passwordFile, directories?}`: a loopback URL and a `chmod 600`
887
+ file holding its password; see [docs/launches.md](docs/launches.md#hub-node)) the same way. `hub node install` writes a
800
888
  launchd agent (macOS, `~/Library/LaunchAgents/io.github.huynle.hub-node.plist`) or a systemd user unit (Linux,
801
889
  `~/.config/systemd/user/hub-node.service`) from the templates in `scripts/`, with the runtime and CLI entry running
802
890
  the command (so `hubctl node install` from a hub session runs the bundle with the OpenCode binary) and
@@ -1050,8 +1138,11 @@ not persist those potentially sensitive details.
1050
1138
  keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
1051
1139
  interrupt the root turn at once.
1052
1140
  - **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,
1141
+ `decline`, `inbox`, `who`, `status`, `whoami`, `intake` and `intake_update` (`src/spoke/hub-tool.ts`). It acts as the calling session
1142
+ with that session's own hub token (the token never appears in the tool's output). `name {name}` sets the
1143
+ session's own @name (the old one stays an alias for 24 h; `name_taken`, invalid and reserved names fail as in
1144
+ `hub name`), `role_claim {role, project?, exclusive?}` and `role_release {role, project?}` its own roles; these
1145
+ take no `session`: a session changes only itself. `ask` with `wait` (seconds,
1055
1146
  at most 600) blocks for the answer until the tool call is stopped; without it the answer arrives later as a hub
1056
1147
  message. Messages from other sessions are shown in the hub's envelope and are not from the user.
1057
1148
  - Child text and execution events are paired by the hub. The latest assistant message of a successful
@@ -1093,6 +1184,7 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
1093
1184
  | `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
1185
  | `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
1186
  | `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
1187
+ | `HUB_DATABASE_STATEMENT_TIMEOUT_MS` | `30000` | cluster mode: Postgres `statement_timeout` of the hub's pools (`0` turns it off) |
1096
1188
  | `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
1097
1189
  | `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
1098
1190
  | `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
@@ -1119,6 +1211,26 @@ the Docker image, npm package and binaries ship the build). One store fed by the
1119
1211
  terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
1120
1212
  title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
1121
1213
  (rename, pin, archive, stop, Allow/Disallow Voice).
1214
+ - **Primary navigation** above the session groups: **All sessions**, **Artifacts** (the library across workspaces,
1215
+ [docs/artifacts.md](docs/artifacts.md)), **Watchtower** ([docs/watchtower.md](docs/watchtower.md)) and **Agent
1216
+ profiles** ([docs/profiles.md](docs/profiles.md)). The sidebar's **Workspaces** tree (drag to supervise) and the
1217
+ session ⋯ menu's **Start side chat…** are described in [docs/workspaces.md](docs/workspaces.md).
1218
+ - **Split panes**: **Open beside** in a session's `⋯` menu (table, sidebar, phone list) opens it next to the
1219
+ focused pane, up to 4 panes, side by side, stacked or in a grid (the icon switch in the bar above the panes).
1220
+ On desktop you can also **drag** a session row (sidebar or Workspaces tree), an artifact (Artifacts library) or
1221
+ the Watchtower detail's **Drag into a pane** handle onto a pane: an edge splits there (left/right side by side,
1222
+ top/bottom stacked), the centre replaces that pane, and the shape reflows (a pane dropped across a side-by-side
1223
+ pair makes a grid). Off the pane view a small map of your panes appears to drop on. Drag a pane's grip (⠿ in
1224
+ its header) to move it the same way. A session already open moves instead of opening twice; a fifth pane is
1225
+ refused with a notice. The `⋯` menu's **Open in pane → left/right/top/bottom** does the same from the keyboard
1226
+ (beside the focused pane). Drags carry no session ID or link outside the console. The focused pane
1227
+ (accent line) owns the composer, Dictate and Voice; the URL is its session, and following a link replaces
1228
+ 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
1229
+ arrows) move it; Ctrl+Alt+W or ✕ closes it. Closing a pane never stops its session, and a pane whose session
1230
+ vanishes closes with a notice. Drag the separators, or focus one and use the arrows (16px, Shift 64px),
1231
+ Home/End; double-click evens them. Which panes are open is kept per device (`hub.paneLayout.v1`), their sizes
1232
+ too (`hub.paneSizes.v1`); nothing is sent to the hub. On a phone the panes are tabs (arrow keys between
1233
+ tabs); only the selected one is on screen and streams live text.
1122
1234
  - **Archive**: **Archive** in a session's `⋯` menu takes it out of the table, the sidebar's groups, Recents and
1123
1235
  Running, on all your devices (with **Undo**). Nothing is deleted and OpenCode's history is untouched:
1124
1236
  archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive
@@ -1218,12 +1330,16 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
1218
1330
  a task or ask a question" (images can be attached), and under it where it runs: **Environment** (your
1219
1331
  machines on this hub: online first with their process and OpenCode version, offline ones listed but not
1220
1332
  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
1333
+ folders, then known folders, with full paths; the chosen folder's full path shows under the chips; switching
1334
+ the environment never carries a folder over, not even a path both machines report: the chip says "Choose a
1335
+ project" until you pick one of that machine's own), the folder's **branch** (from the summary of the latest session in that
1222
1336
  folder, when there is one) and **Open in Herdr tab** (a Herdr workspace on a machine that runs Herdr; on
1223
1337
  by default). Submitting sends `POST /api/machines/:id/sessions {directory, herdrWorkspace?}`, opens the
1224
1338
  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
1339
+ environment and folder are remembered on the device (`localStorage` `hub.newSession`); the remembered folder is
1340
+ preselected only while that machine still reports exactly that folder, and nothing else is guessed: otherwise the
1341
+ chip says "Choose a project", and the send arrow stays grey (disabled, `aria-disabled`) until a project is chosen
1342
+ (CON-12). The group "+" preselects with `?machine=&key=&dir=`. "Opened in Herdr · <workspace>" confirms the new tab; if Herdr
1227
1343
  failed, the session still exists and the hub's `warning` is shown. 409 (background service not
1228
1344
  connected) is shown under the composer.
1229
1345
 
@@ -1873,8 +1989,12 @@ npm run build:cli # packages/cli/dist/hub-cli.js, the hub CLI bundle (prepack
1873
1989
 
1874
1990
  Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
1875
1991
  `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).
1992
+ `npm run test:e2e` (scripts/test-e2e.sh) runs every headless Chromium suite, each alone: the smoke test against the
1993
+ mock hub at desktop and phone sizes (with the nav, profiles, workspace, Watchtower, team editor and artifact suites
1994
+ it chains), then the real-hub suites `web/e2e/artifact-host.ts`, `web/e2e/artifact-bridge.ts` and
1995
+ `web/e2e/hwa-workflow.ts` (two simulated machines: side chat launch and Send to main, a declared action from an
1996
+ embedded app). Build `web/dist` first; `CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to
1997
+ the temp dir. CI does not run them (no Chromium there): `scripts/test-ci.sh` runs the bun tests.
1878
1998
 
1879
1999
  `test/e2e-phase2.test.ts` does the same for messaging: the OpenCode spoke (with `deliver`) and a spoke without it,
1880
2000
  `hub msg`/`ask`/`mute` as session tokens, replies through the `hub` tool's code path, an app token with the
@@ -1900,7 +2020,9 @@ second user. The opt-in `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.tes
1900
2020
  `opencode serve` (own port, password and XDG dirs) with a local stub model and checks a real shell: its
1901
2021
  `HUB_SESSION_TOKEN`, and that `hub whoami` there prints the session. `HUB_IT_OPENCODE=1 bun test
1902
2022
  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
2023
+ message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin.
2024
+ `HUB_IT_OPENCODE=1 bun test test/e2e-quiet-delivery.it.test.ts` sends `wake: false` messages through a real hub to an
2025
+ 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
2026
  that the hub sees the session's token while the model and the transcript never do. `HUB_IT_OPENCODE=1 bun test
1905
2027
  test/spoke-prompt-refs.it.test.ts` checks that a prompt naming a session reaches the model unchanged with the
1906
2028
  reference note attached, and that hub-sourced prompts get none.
@@ -1914,6 +2036,13 @@ with a throwaway `HOME`, `CLAUDE_CONFIG_DIR` / `CODEX_HOME` and a deny-all proxy
1914
2036
  `bun packages/spoke-sdk/scaffold.ts <name> <dir>`, whose output passes `npx hub-conformance spoke --driver "bun
1915
2037
  <dir>/driver.ts"` as written ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
1916
2038
 
2039
+ `test/e2e-hwa-*.test.ts` (part of `npm test`) run workspaces, profiles, side chats, artifacts, AI jobs, teams and the
2040
+ Watchtower graph end to end across two simulated machines (`test/e2e-hwa-kit.ts`: each a node plus a service spoke,
2041
+ real launch engine): a workspace bound on both, supervision, board, digests and checkpoints, disconnect/reconnect,
2042
+ master replacement, archive and a restart that keeps everything; profile acks; jobs handled on the other machine;
2043
+ teams through `POST /mcp`; load caps; a record from a newer hub refused, never overwritten; and a tenant matrix
2044
+ that sends every parameterised route of these features as another user (answers like an unknown ID).
2045
+
1917
2046
  Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
1918
2047
  `{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
1919
2048
  OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
5
5
  "license": "MIT",
6
6
  "author": "Huy Le",
@@ -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.3",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -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>()
@@ -3,7 +3,8 @@
3
3
  // --input-format stream-json --output-format stream-json --verbose [--model m] [--agent a]`, so its hooks reach the
4
4
  // daemon like any `hub claude` run (HUB_CLAUDE_LAUNCH, HUB_CLAUDE_SOCKET), a turn is a user message written to its
5
5
  // stdin, the turn's end is the result line on its stdout, and an interrupt is a control request (Esc has no hook,
6
- // a control request works). Turns are answered in order (Claude Code runs queued user messages one after another).
6
+ // a control request works). A profile's model (HWA-17) is the control request set_model, confirmed by Claude Code's
7
+ // control response. Turns are answered in order (Claude Code runs queued user messages one after another).
7
8
  // When claude exits its open turns fail and onExit reports it; stopAll closes stdin (claude ends after the running
8
9
  // turn) and kills what lingers.
9
10
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
@@ -44,7 +45,8 @@ interface Turn {
44
45
  interface Proc {
45
46
  readonly child: ChildProcessWithoutNullStreams
46
47
  readonly turns: Turn[]
47
- readonly controls: Map<string, () => void>
48
+ /** Open control requests: called with Claude Code's response, or nothing when it exited. */
49
+ readonly controls: Map<string, (response?: Record<string, any>) => void>
48
50
  readonly exited: Promise<void>
49
51
  stderr: string
50
52
  }
@@ -224,14 +226,32 @@ export class ClaudeLauncher {
224
226
  const proc = this.#procs.get(sessionID)
225
227
  if (!proc) return false
226
228
  for (const turn of proc.turns) turn.interrupted = true
227
- const id = `hub_int_${++this.#controlID}`
228
- const answered = new Promise<void>((resolve) => proc.controls.set(id, resolve))
229
- proc.child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: id, request: { subtype: "interrupt" } })}\n`)
230
- await Promise.race([answered, sleep(CONTROL_WAIT_MS)])
231
- proc.controls.delete(id)
229
+ await this.#control(proc, { subtype: "interrupt" }, CONTROL_WAIT_MS)
232
230
  return true
233
231
  }
234
232
 
233
+ /**
234
+ * Changes the session's model (control request set_model, a Claude Code model ID or alias); resolves when Claude
235
+ * Code confirms, rejects with its error, when it does not answer in `waitMs`, or for a session it did not start.
236
+ */
237
+ async setModel(sessionID: string, model: string, waitMs = CONTROL_WAIT_MS): Promise<void> {
238
+ const proc = this.#procs.get(sessionID)
239
+ if (!proc) throw new Error(`Session ${sessionID} was not started by the hub here (only hub-launched Claude Code sessions change model from the hub)`)
240
+ const response = await this.#control(proc, { subtype: "set_model", model }, waitMs)
241
+ if (!response) throw new Error("Claude Code did not confirm the model change")
242
+ if (response.subtype !== "success") throw new Error(typeof response.error === "string" && response.error ? response.error.slice(0, 200) : "Claude Code refused the model change")
243
+ }
244
+
245
+ /** Sends a control request; resolves with Claude Code's response, or undefined (no answer in time, or it exited). */
246
+ async #control(proc: Proc, request: Record<string, unknown>, waitMs: number): Promise<Record<string, any> | undefined> {
247
+ const id = `hub_ctl_${++this.#controlID}`
248
+ const answered = new Promise<Record<string, any> | undefined>((resolve) => proc.controls.set(id, resolve))
249
+ proc.child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: id, request })}\n`)
250
+ const response = await Promise.race([answered, sleep(waitMs).then(() => undefined)])
251
+ proc.controls.delete(id)
252
+ return response
253
+ }
254
+
235
255
  /** Ends every process: stdin closed, then SIGTERM and SIGKILL for what lingers. */
236
256
  async stopAll(): Promise<void> {
237
257
  const procs = [...this.#procs.values()]
@@ -258,8 +278,8 @@ export class ClaudeLauncher {
258
278
  const turn = proc.turns.shift()
259
279
  turn?.resolve(turnResult(line, turn.interrupted))
260
280
  } else if (line.type === "control_response") {
261
- const id = record(line.response).request_id
262
- if (typeof id === "string") proc.controls.get(id)?.()
281
+ const response = record(line.response)
282
+ if (typeof response.request_id === "string") proc.controls.get(response.request_id)?.(response)
263
283
  }
264
284
  }
265
285
  }