@huynle/hub 0.7.4 → 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 +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  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 +632 -49
  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/CHANGELOG.md CHANGED
@@ -2,6 +2,223 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0
6
+
7
+ Workspaces, supervision, split panes and side chats, shared agent profiles, persistent AI-powered artifacts, teams,
8
+ and the Watchtower. All new state is additive. Cluster hubs need `hub-server migrate` (schema v2) before the new
9
+ pods start. See [docs/operations.md](docs/operations.md) for the rollout, rollback, backup and front-proxy notes.
10
+
11
+ ### Fork and revert a message
12
+ - Hovering (or focusing) one of your messages in the console shows **Copy · Fork · Revert**, like the OpenCode app;
13
+ on a phone, long-press it or tap its ⋯ button. **Fork** opens a new session with the conversation before the
14
+ message and the message in its composer. **Revert** hides the message and everything after it, restores the files
15
+ the session changed since then (confirming first, with the list), and puts the message back in the composer; a
16
+ banner offers **Restore**; sending the next message makes it permanent. Works across devices and in panes.
17
+ - New console routes (user-only, audited): `POST /api/sessions/:id/fork`, `GET …/revert/preview`, `POST …/revert`,
18
+ `POST …/revert/restore`, `POST …/revert/commit`. Spoke features `fork` and `revert` (OpenCode processes that reach
19
+ their own server) with ops `fork`, `revertPreview`, `revert`, `revertRestore`, `revertCommit`; a staged revert is
20
+ reported as `SessionInfo.revert` (additive: older hubs ignore it). Other harnesses show the actions disabled
21
+ ("not supported yet"). Contract: [docs/session-view.md](docs/session-view.md#fork-and-revert-a-message).
22
+
23
+ ### Workspaces and supervision
24
+ - **Workspaces** ([docs/workspaces.md](docs/workspaces.md)): durable, cross-machine coordination units with host repo
25
+ bindings, members (`master`/`worker`/`observer`, generations), explicit assignments and a supervision graph
26
+ (acyclic, ≤ 6 levels) kept apart from native `parentID`. Membership and supervision grant nothing. New routes under
27
+ `/api/agent/workspaces…`, SDK `hub.workspaces`, 15 `workspace_*` MCP tools, and compact `workspace.*` events.
28
+ - **Drag to supervise**: the sidebar's **Workspaces** tree shows each workspace's members under their supervisors.
29
+ Drag (or use Alt+arrows or the Move menu) to supervise, reorder or detach. "Spawned by" and "supervised by" are
30
+ shown separately.
31
+ - **Situation board** (`GET …/board`): one bounded, text-free snapshot of members, presence, blockers, assignments,
32
+ Decisions and artifacts, with `stale`/`omitted`/`failed` sections instead of guesses.
33
+ - **Supervisor digests and checkpoints**: masters and supervisors get batched, at-least-once digests as hub messages
34
+ from `app:workspace-coordinator`. `POST …/checkpoints` acknowledges them durably (flushed before the answer).
35
+ - **Quieter digests**: routine updates (including snapshots) are delivered without waking an idle supervisor (2 min
36
+ quiet, 15 min max) and repeats of already-known states are dropped; assignment closures wake without interrupting
37
+ (30 s quiet, 5 min max); blockers, errors and urgent snapshots still wake and interrupt. A cleared error is still
38
+ reported, followed by the recovery. A member's session that disappears and comes back within one batch (a spoke
39
+ restart or reconnect) is not reported.
40
+ - **Master replacement** (`POST …/master {sessionID | launchID}`): explicit, fenced, with the next generation. Supervised
41
+ members move to the new master. No grants move.
42
+
43
+ ### Panes and side chats
44
+ - **Split panes**: **Open beside** shows up to 4 sessions or artifacts side by side, stacked or in a grid. The focused
45
+ pane owns the composer and Voice. Layouts are per device. On phones the panes are tabs. `/api/events` takes
46
+ `sessions=a,b` for the visible panes.
47
+ - **Drag into panes**: drag a session (sidebar, Workspaces tree, Watchtower detail), an artifact (library) or a pane
48
+ (header grip) onto a pane: edges split side by side or stacked, the centre replaces, the layout reflows to a grid
49
+ when needed, and a fifth pane is refused with a notice. **Open in pane → left/right/top/bottom** in the session
50
+ menu is the keyboard equivalent. The layout switch is now a compact segmented control with icons.
51
+ - **Side chats**: **Start side chat…** copies a bounded snapshot of a session into a new session on the workspace's
52
+ host binding (any machine), in a pane beside it, with **Refresh** and a confirmed **Send to main**. Neither
53
+ interrupts a running turn.
54
+
55
+ ### Agent profiles
56
+ - **Shared, versioned profiles** ([docs/profiles.md](docs/profiles.md)): instructions, skills and commands (with
57
+ `{{param}}`, `{{arguments}}` and spoke-side `{{secret:NAME}}`), immutable versions, and import from
58
+ `AGENTS.md`/`CLAUDE.md`, `skills/*/SKILL.md` and `commands/*.md`. You pin an exact version to a session at runtime
59
+ (the profile chip), to a launch (`agentProfile`) or to a team role. The **Agent profiles** page is the library.
60
+ **Ctrl+Alt+P** inserts a rendered command into the composer.
61
+ - **Per-harness application** through the new spoke feature `profileApply`, with honest acknowledgments (`applied`,
62
+ `partial`, `restart_required`, `unsupported`, `rejected`, `pending`): OpenCode (system prompt + `hub_profile`
63
+ tool), Claude Code (hook context, `set_model` in launched sessions), Codex (developer message + skill files);
64
+ presets are always `unsupported`. Local instructions win. No global harness config is written.
65
+ - **Profile metadata and usage**: optional `metadata` and `projects` on a profile, and **Used by**
66
+ (`GET /api/agent/profiles/:id/usage`, SDK `hub.profiles.usage()`, MCP `profile_usage`): pinned sessions with
67
+ requested vs running version and an explicit Upgrade, team roles, launches and workspaces, each shown only under the
68
+ caller's existing read rules (`hidden` names what was left out). The library rows show usage chips, with filters and
69
+ sort.
70
+
71
+ ### Artifacts and AI
72
+ - **Persistent artifacts** ([docs/artifacts.md](docs/artifacts.md)): revisions of content-addressed blobs (files
73
+ standalone, chunked Postgres in a cluster), `private` or `workspace` visibility, archive and delete, staged uploads up
74
+ to 25 MiB, and downloads always served as attachments. New routes, SDK `hub.artifacts`, and `artifact_*` MCP tools.
75
+ - **Viewers and dashboards**: markdown, code, images, tables, charts, and declarative dashboards (schema v1, bindings
76
+ to other artifacts only, no URLs or scripts), plus a text editor with conflict-safe drafts.
77
+ - **AI jobs**: ask, propose an edit or run a declared action. The job runs on a handler you choose (a new launch or an
78
+ existing session), always with the requester's authority. Proposals apply only when you **Accept**. Jobs survive hub
79
+ restarts, and each job times out after 2 h.
80
+ - **Viewer redesign**: a header bar with kind icon, version picker (**Back to latest**), Copy, Download, Edit, Open in
81
+ pane and a details popover; content in a framed card (code with file/language header, line numbers, wrap; markdown
82
+ at reading width; dashboards and apps full-bleed); inline editing; and AI as a chat-style side panel (thread,
83
+ composer, Ask / Edit / Action or slash commands, compact handler picker that never preselects), resizable, a
84
+ bottom sheet on phones.
85
+
86
+ ### Isolated artifact host
87
+ - Interactive `html`/`app` artifacts run only at `/artifact-host/v1/<capability>/…`: an opaque-origin sandbox with a
88
+ strict CSP, short-lived, viewer-bound and revocable capabilities, and a bridge (`resize`, `askAI`). Every `askAI`
89
+ becomes a consent card in the artifact's AI panel thread ([docs/artifact-host.md](docs/artifact-host.md)).
90
+
91
+ ### Teams
92
+ - **Teams** ([docs/teams.md](docs/teams.md)): versioned definitions with roles, one entry role, `open` or `restricted`
93
+ communication paths (`one-way`/`two-way`, `msg`/`ask`), and a draft → publish flow. A session can belong to many
94
+ teams. The hub **enforces** the policy on delivery (`403 forbidden` with `extra.policy`). Team members cannot contact
95
+ sessions outside their teams unless a role has `externalContact`. Sessions in no team are unchanged.
96
+ - **Staffing** with existing or newly launched sessions under your grants (`403 needs_user` lists what is missing).
97
+ **Intake** gives work to the team's entry role and tracks it as an assignment ([docs/teams-intake.md](docs/teams-intake.md)).
98
+ - **Team addresses** `@@role#team` and `@@role.project#team` (quote `#` in shells). `/who`, `status` and `show` are
99
+ team-aware for team members.
100
+ - Malformed addresses answer `Not an address: use ses_…, @name, @@role or @@role#team` with a syntax-only hint (for
101
+ `@engineer#support`: `@@engineer#support` or `@engineer`); the hint never reveals whether a name exists.
102
+ - Entry-role sessions report and close their intake from the harness: OpenCode `hub` tool verbs `intake` and
103
+ `intake_update`, MCP `team_intake_update` (`expectedRevision` now optional). The intake message says how.
104
+ - **Teams page** (`/teams`, in the primary navigation): team cards (mode, entry role, seats, vacant roles, open work),
105
+ **New team** in a dialog, and per team **Members** (one block per role, **+ Add member** with a searchable session
106
+ picker that shows other memberships, **Launch new …**), **Work** (Submit a task, live intake status with links to
107
+ the entry session and its assignment), **Communication** and **Details** (draft → Review and publish, history),
108
+ Pause/Archive and **Edit in Watchtower**. The Watchtower's team panel uses the same components and links back.
109
+ - **Team isolation** (owner setting, default off): Settings → Teams, or `GET/PUT /api/agent/team-settings`
110
+ `{teamIsolation, expectedRevision}` (users only; SDK `hub.teams.settings()` / `setSettings()`). On: team members
111
+ see only their teams' sessions.
112
+
113
+ ### Watchtower
114
+ - Renamed from **Bird's-eye view** (new lookout icon). `/birds-eye` links redirect to `/watchtower` with their
115
+ filters, and the browser's saved view and presets (`birdsEye.*`) move to `watchtower.*` once.
116
+ - A full-screen page ([docs/watchtower.md](docs/watchtower.md)) of machines, sessions, you, your apps and teams, with
117
+ live message flow: 3D (lazy `3d-force-graph`), a deterministic 2D canvas, or a list (also used for reduced motion).
118
+ It has URL-state filters and presets, team clusters, drawn vs actual paths and denied attempts, and a **team
119
+ editor** to assemble, staff, publish and submit work. New read API `GET /api/agent/graph` (+ `/edges/:id`,
120
+ `/nodes/:id`).
121
+ - Graph first: the controls are a floating **dock** at the bottom centre (view, Filters with an active count, Legend,
122
+ Teams, Hub details, Reset view, Pause motion, Search) that fades when idle. **Filters** is closed by default and
123
+ shows only the filters you add; active filters are removable chips over the canvas. The **Legend** has a visible
124
+ close and Escape closes it. On phones, filters, legend, details and the team editor are bottom sheets.
125
+ - A look in the style of Brain Graph (MIT, credited in the docs): glowing points in coloured group regions, fibers that
126
+ brighten towards the receiver, depth of field, labels on the most connected nodes, and a stable layout kept per
127
+ device (`watchtower.layout`). Flow pulses fade with message age and stop once nothing on a link is working or
128
+ pending. Rendering is on demand (none while still or hidden), and the 3D view draws in about 25 batched calls.
129
+ - Staffed seats are tied to their role by a tether and vacant roles are drawn hollow; the legend uses the same glyphs;
130
+ **More → Re-layout** clears the saved layout; the empty state sits below the hub core with **Connect a machine**.
131
+
132
+ ### Navigation
133
+ - A primary navigation above the session groups: **All sessions**, **Artifacts** (a library across workspaces),
134
+ **Watchtower** and **Agent profiles**.
135
+ - **Settings sections**: General, Notifications, Voice & speech, Machines & devices, Agents & access, Teams and Admin,
136
+ each its own page `/settings/<section>` (deep links, back/forward; old `/settings#speech`-style links still work).
137
+ A section sidebar on desktop, a section list with drill-in on phones, and a **Search settings** box that jumps to a
138
+ setting and highlights it. Notification kinds are collapsed by group with an on/off summary; the device's push
139
+ status is one line with **Details**.
140
+
141
+ ### Security hardening
142
+ - **Bounded request bodies** everywhere: agent JSON 1 MiB, profiles 4 MiB, console JSON 4 MiB, `/mcp` 1 MiB, staged
143
+ blobs 25 MiB, all read by a rate-budgeted streaming reader. Oversized bodies are refused with `Connection: close`.
144
+ **Set a front-proxy request-body limit (26 MiB)**: the k8s ingress example now uses `proxy-body-size: 26m` (it was
145
+ `16m`).
146
+ - A session seated in a team no longer sees sessions seated only in *other* teams: they are left out of `who`,
147
+ `status`, `show`, `log`, `/watch` and `/events`, and their addresses answer `404` like unknown ones. Sessions in no
148
+ team stay visible unless the owner turns on `teamIsolation` (Settings → Teams). Users, apps and team-less sessions
149
+ keep the owner-wide view.
150
+ - New app-token scopes `workspaces:*`, `profiles:*`, `artifacts:*` and `teams:*`. **Existing tokens gain nothing**,
151
+ and session tokens are refused on workspace routes.
152
+ - **Plain http only by opt-in**: `hub login` (device code, `--token` or a piped token) and `hub`/`hubctl machine enroll`
153
+ refuse plain http except on loopback, or with `--allow-insecure-http` for a private-network IP address (never a
154
+ host name), with a warning every time; nothing remembers the flag, and later commands using a stored plain-http
155
+ login warn on stderr. A loopback hub's approval link on this machine's LAN address is shown; other origins are
156
+ replaced with a warning.
157
+
158
+ ### Fixes
159
+ - Counts pluralise correctly ("2 OpenCode processes", not "processs"); a team role's seat counter reads "1 of 2
160
+ required", "3 members · up to 16" or "full (16)" instead of "1 of 0–16".
161
+ - The artifact AI panel lists handler sessions by `@name` (searchable by name, title or ID) and names a handler picked
162
+ in the console by its `@name` instead of its session ID.
163
+ - The console's live stream opens at once when it resumes (the hub writes a first chunk immediately); before, it waited
164
+ for the 20 s heartbeat and **Reconnecting…** stayed up, across pages. Over plain http, Chrome still allows only 6
165
+ connections per host, so more than about 6 console tabs stall; https avoids this.
166
+ - The sidebar, Details rail and pane resize handles show their resize pointer again (a hidden character had broken
167
+ those CSS rules).
168
+ - `hub node` keeps OpenCode's background service warm (loopback only, every 30 s) so a freshly started service's
169
+ machine comes online without anyone opening a project (OpenCode has no server-start plugin hook).
170
+ - **Routine `wake: false` messages no longer cost an OpenCode session a turn** (routine digests, `--no-wake`
171
+ messages). OpenCode gives every queued inbox item its own model call once a turn ends, resume or not, so a
172
+ supervisor answered each routine digest in a turn of its own. The OpenCode spoke now admits every non-waking
173
+ message steered without resume, and refuses a routine one (a `wake: false` `msg` that is not urgent, to a top-level
174
+ session) while a turn runs: the hub keeps it `pending` and delivers it after the turn, so it reaches the model with
175
+ the next real turn, before its prompt (CLI and MCP say "pending (offline or busy; …)"). A waking message to the
176
+ same session sends the pending ones first (if the session is still busy, the waking one goes first, queued after
177
+ the turn). A flush stops at a session's first failed delivery, so a busy session costs one try per sessions frame.
178
+ Urgent
179
+ ones, asks, replies, primers and anything for a subagent still go into a running turn. Live check:
180
+ `HUB_IT_OPENCODE=1 bun test test/e2e-quiet-delivery.it.test.ts`.
181
+ - **Repeated blocks of one member no longer wake its supervisor each time**: blocked again for the same reason (same
182
+ blocker kind, or the same blocked/error state) within 15 min of a wake about it that the hub carried out, the
183
+ digest line is routine.
184
+ - **A run that fails at once shows as failed** (a model or provider that does not exist, auth, quota). OpenCode ends
185
+ such a run within ~50 ms of `session.created`, before the spoke's debounced sessions snapshot (500 ms), and the hub
186
+ keeps status only for sessions it was told about, so it dropped the failure and listed the session idle: no banner,
187
+ no "!" badge, not under Needs you, no `providerError` push (also in 0.7.3). The spoke now sends the snapshot first
188
+ when a run event is about a session the hub has not been told about yet (creating sessions alone still waits for the
189
+ debounce), and resends a failure once a snapshot lists its session if the session's details were still being
190
+ fetched when it failed. Live check: `HUB_IT_OPENCODE=1 bun test test/spoke-run-failure.it.test.ts`.
191
+ - **A session is never routed to another harness's spoke.** When no process owns a session and none of its
192
+ reporters is a service, the hub falls back to a background service on the reporter's machine; it took any service,
193
+ so an OpenCode session reported by a foreground `opencode serve` on a machine that also runs the Claude Code (or
194
+ Codex) service went to that service: "The claude-code spoke does not serve "timeline"" and `unsupported: summary`
195
+ (also in 0.7.3). The fallback now takes only a service of the session's own harness, else the reporter itself.
196
+
197
+ ### Runtime
198
+ - **Bun 1.4.4**, pinned: the image (`oven/bun:1.4.4-slim` by digest, both stages), CI and release workflows, and
199
+ `engines.bun` (`>=1.4.4`). Bun 1.3.x's `Bun.SQL` auto-pipelining could stall a pooled Postgres connection under
200
+ concurrent writes (oven-sh/bun#33985; fixed in Bun 1.4.0 by oven-sh/bun#33627): a cluster pod's state queries then
201
+ never answered (the supervision IT's "many concurrent internal moves" hung or timed out in 3 of 15 runs on 1.3.5,
202
+ 0 of 40 on 1.4.4).
203
+ - Cluster: the Postgres pools set `statement_timeout` (30 s by default, `HUB_DATABASE_STATEMENT_TIMEOUT_MS`, `0`
204
+ off), so a query stuck on the database fails with an error instead of holding its caller and connection.
205
+ - Bun 1.4 enforces the server's request ceiling on chunked bodies too; the ceiling now leaves 16 MiB above the
206
+ largest route bound, so an over-limit upload still gets the route's own `400`/`413` with `Connection: close`. Bun
207
+ 1.4 then closes the socket instead of reading and discarding whatever the client keeps sending.
208
+ - The image build copies the protocol and SDK sources into the console stage, which imports them (without them
209
+ `docker build` failed in the console's `vite build`).
210
+
211
+ ### Behavior notes
212
+ - New agent error code `conflict` (409, with `currentRevision`) for stale `expectedRevision`s and reused
213
+ `Idempotency-Key`s. The SDK `ErrorCode` includes it, and the CLI exits 3.
214
+ - The server's request ceiling rises from about 10 MiB to about 41 MiB (25 MiB blobs plus drain headroom). A JSON body
215
+ that was accepted before can now be refused as too large (by its route's bound).
216
+ - SDK `parseAddress` refuses `#` in a role's project scope (`@@role#team` is only ever team-resolved).
217
+ - Send to main and AI jobs handled by a session use normal delivery: they never interrupt a running turn. The first
218
+ message to an idle session right after the hub's primer may be delivered as steer.
219
+ - Cluster: schema v2 (`artifact-blobs`). Run `hub-server migrate` before rolling pods, and don't create restricted
220
+ teams while old and new pods are mixed.
221
+
5
222
  ## 0.7.4
6
223
 
7
224
  Release downloads move to a public repository. The source repository is private now, so the standalone `hub`
package/Dockerfile CHANGED
@@ -7,6 +7,8 @@
7
7
  # Legacy VOICE_* variables still work, except that the HUB_HOST/HUB_PORT/HUB_STATE_DIR defaults below
8
8
  # win over VOICE_HOST/VOICE_PORT/VOICE_STATE_DIR: rename those overrides to HUB_*.
9
9
  # The console (web/, Solid + Vite) is built in its own stage; the image ships only web/dist.
10
+ # Bun is pinned (tag and multi-arch index digest) to 1.4.4: it has the Bun.SQL pipelining fix (oven-sh/bun#33627,
11
+ # in since 1.4.0; 1.3.x stalls pooled Postgres connections in cluster mode). Bump both stages together.
10
12
  FROM node:22-slim AS web
11
13
  WORKDIR /build
12
14
  COPY package.json package-lock.json ./
@@ -24,9 +26,12 @@ COPY packages/preset/package.json ./packages/preset/
24
26
  COPY packages/codex/package.json ./packages/codex/
25
27
  RUN npm ci --no-audit --no-fund --ignore-scripts
26
28
  COPY web ./web
29
+ # The console imports the protocol and SDK sources (web/src/… ../packages/{protocol,sdk}/src).
30
+ COPY packages/protocol/src ./packages/protocol/src
31
+ COPY packages/sdk/src ./packages/sdk/src
27
32
  RUN npx vite build --config web/vite.config.ts
28
33
 
29
- FROM oven/bun:1.3-slim AS deps
34
+ FROM oven/bun:1.4.4-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4 AS deps
30
35
  WORKDIR /build
31
36
  COPY package.json package-lock.json ./
32
37
  COPY packages/protocol/package.json ./packages/protocol/
@@ -47,7 +52,7 @@ COPY docker/hub-deps.ts ./
47
52
  # Keep only what the hub loads at runtime (the @opencode/* packages are for the plugin).
48
53
  RUN bun hub-deps.ts /deps web-push
49
54
 
50
- FROM oven/bun:1.3-slim
55
+ FROM oven/bun:1.4.4-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4
51
56
  LABEL org.opencontainers.image.title="hub" \
52
57
  org.opencontainers.image.description="Hub: a central hub for AI agent harnesses (OpenCode first): remote console, pairing, push and optional Voice" \
53
58
  org.opencontainers.image.licenses="MIT" \
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 |
@@ -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