@huynle/hub 0.8.0 → 0.8.2

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 (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,331 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.8.2
4
+
5
+ A follow-up to 0.8.1. Voice keeps a request with pauses together as one turn (and fetches its cue phrases once per
6
+ page), and the Concierge's approvals without a grant
7
+ become Decisions for you (never rules first, never "always", the current Concierge only). OpenCode fork and revert
8
+ are completed (clear busy/not-found errors, agent messages wait while a revert is staged, Fork and open beside), the
9
+ profile skill/command check reads the session's own folder, the console names grants and Activity in plain words,
10
+ and a tool call with a large screenshot opens with a placeholder instead of failing. No migration is needed.
11
+
12
+ ### Concierge
13
+ - **The board moves after the conversation** (V-01). The Concierge's system prompt now ends with its stable
14
+ instructions and notes; the board (whose text changes as it ages) is sent after the conversation, with the spoke's
15
+ own cache breakpoints and tools in name order, so a board update should no longer invalidate the cached
16
+ conversation. On the OmniRoute route measured on the preview the cache still misses after a 20–40 s gap
17
+ (about 6.9k tokens read), so the cost per turn is unchanged there for now; the cause is being investigated.
18
+ - **Concierge: "Recommended" only when it advises** (R-03). Its approve escalations no longer mark **Approve once**
19
+ as recommended by default: only an approve with `recommend: true` (hub tool, SDK `approve({recommend})`, route body
20
+ `recommend`) recommends it; otherwise its why is a note after the question and the permission card shows no
21
+ "recommends Approve". Other agents' escalations are unchanged.
22
+ - **Concierge: an approve without a grant becomes a Decision** (R-03). It was refused ("No grant lets this session
23
+ approve in this session") although its instructions promised a Decision; it now answers needs_user and opens the
24
+ escalation Decision at the hub's tier (only the current Concierge session, never a retired one or after a failed
25
+ identity read; never rules and self-tree still refuse; other agents unchanged).
26
+ - **Never rules come first for the Concierge's "always"** (R-03 verify). A Concierge approve with `always` on a request
27
+ a never rule (the hub's or its grant's) forbids opened a tap Decision offering "Approve always"; it is now
28
+ forbidden, with or without a grant. Concierge escalation Decisions never offer Approve always (once or deny only). Prompt
29
+ v8 says only approvals escalate (other verbs are proposed) and when to set `recommend`.
30
+ - **The board is never the user's words** (V-01 verify). On OpenAI-compatible and older Claude routes OpenCode
31
+ delivers the board (now after the conversation) as `<system-update>` text inside a user turn; prompt v8 tells the
32
+ Concierge the board comes from the hub, never from the user, and its text is data, never a request.
33
+ - **Approving a Concierge prompt proposal on a session with a staged revert answers 409 `conflict`** (not 500); the
34
+ Decision stays open and nothing is sent. Decision answers now use the agent API's status for every code
35
+ (`busy` 503 with `retryAfter`).
36
+
37
+ ### Voice
38
+ - **Voice: one utterance per turn.** A pause no longer cuts the user off: after a segment ends, the endpointer waits
39
+ 1.0 s (finished-looking text: STT adds a period to every fragment) or 1.6 s (text ending in "the", "and", a comma…)
40
+ before it sends, counted from the segment's end so STT speed no longer moves the threshold. Speech that starts
41
+ within that window joins the same utterance, and nothing is sent while the user is speaking. With the VAD's
42
+ ~0.43 s to end a segment and ~0.25 s to start one, pauses up to about 1.1 s (finished) or 1.7 s (unfinished) stay
43
+ one turn (before: every pause of 0.45 s or more after finished-looking text was a separate turn, so one request
44
+ with two pauses became three model turns). Voice commands ("stop", "cancel"…) and the 60 s cap still send at
45
+ once; barge-in is unchanged. Finished-looking turns may start up to ~0.3 s later than before (STT already takes
46
+ ~0.7–1 s of the window). Timings: `VOICE_TIMINGS.finishedHoldMs` / `holdMs` in `web/src/core/conversation-core.ts`.
47
+ - **Voice: cue phrases are fetched once per page.** The ~20 progress phrases ("Let me see." …, ~0.75 MB) are kept
48
+ in the page's memory per TTS voice, so a second Voice start fetches nothing, also where Cache Storage is not
49
+ available (plain-HTTP LAN origins, private mode). `/api/speak` is a POST, so HTTP caching does not apply; no hub
50
+ change.
51
+
52
+ ### Profiles
53
+ - **Profile skills and commands give way to the right folder's local ones** (R-02): OpenCode 2.2.3's plugin
54
+ `skill.list` / `command.list` answer for the plugin instance's own folder whatever location is passed, so for
55
+ every session outside it (all `opencode serve --service` sessions: the first instance is `$HOME`) a profile's
56
+ local-precedence check read another folder's names. They are now read like Branch and Changes: through the plugin
57
+ instance of the session's own folder, else the own server's API (which honours the location), else not at all;
58
+ never another folder's. When they cannot be read (no own server to ask, a cold read past 5 s) the apply still
59
+ succeeds as `partial`: the instructions apply and the profile's skills and commands are not offered ("skills and
60
+ commands not offered: the session's local skills and commands could not be read"). Instructions-only profiles do
61
+ not read the names. A folder OpenCode is still loading is read until two reads 0.5 s apart agree, within 5 s.
62
+
63
+ ### Fork and revert
64
+ - **OpenCode fork and revert, completed** (R-01; [docs/session-view.md](docs/session-view.md#fork-and-revert-a-message)):
65
+ - Restore and commit while the session runs answer 409 `busy` with a clear message (the hub checks first; OpenCode's
66
+ "Session is busy" maps to it), and a message that is not one of the session's user messages (or a session
67
+ OpenCode does not have) answers 404 `not_found`: never 502. Spokes put a protocol code on refusals (`Res.code`:
68
+ `busy`, `not_found`; additive, older peers ignore it, older spokes' texts are still recognised).
69
+ - **Agent messages wait while a revert is staged**: the hub holds every hub delivery to the session (msgs, asks,
70
+ replies, its primer) as pending and delivers them, in order, once the revert is restored or committed, so none of
71
+ them commits it; act prompts (agents and the Concierge under a grant, the user's act verb, approved Concierge
72
+ proposals) are refused 409 `conflict`. The banner says "agent messages wait" (decision 3q94ez94).
73
+ - Hub message rows of a committed revert's removed history stay hidden (the spoke reports the window as
74
+ `revertCommitted`; the hub keeps it and `…/messages` leaves those rows out); a revert staged and committed in the
75
+ terminal within one report drops the deleted rows without a reload; messages held while staged stay visible.
76
+ - Context and summary (context usage) leave reverted messages out; a fork that copied the source's hub primer gets
77
+ a hub note that the identity is not its own; revert file paths over 1000 characters count in `filesTotal`.
78
+ - **Fork and open beside**: a user message's actions open the fork in a pane next to the session.
79
+ - Hardening after review: a commit window first reported without its start is still kept (and failed writes are
80
+ retried); the spoke itself refuses hub messages while it knows of a staged revert (the hub keeps them pending);
81
+ an invalid `code` or a malformed `revertCommitted` is ignored instead of dropping the frame (and the spoke's
82
+ connection); an act prompt checks the grant before the staged-revert refusal (403 first) and audits it; expiry
83
+ notices name a staged revert; the fork note frames the source title as data and times out after 5 s; live tests
84
+ give their private servers none of the caller's `OPENCODE_*` / `HUB_*` variables.
85
+
86
+ ### Console and MCP
87
+ - **Answered notes name the grant**: "Approved by @orchestrator · your grant for api, web" (its projects by folder
88
+ name) or "· the Concierge's grant", never "grant g_…"; the grant's ID is in the tooltip. The console loads your
89
+ grants (`GET /api/agent/grants`) with the stream, and again when an entry names a grant it does not know.
90
+ - **Settings → Agents & access → Activity says what each action did in plain words**: approved, denied, answered,
91
+ prompted, stopped, proposed an action, messaged, claimed / released a role, launched, stopped the launch of, staffed
92
+ / unstaffed a team role, a message the team's rules refused, asked for / approved / denied a grant request (instead
93
+ of `team.staff`, `launch.stop`, `roles.release`, …); an unknown action reads as its words without dots. A
94
+ Concierge proposal says what it proposes: "proposed denying a permission request in @engineer", "proposed answering
95
+ a form in …", "proposed a prompt to …", "proposed stopping …".
96
+ - **A turn a denied permission stopped reads "Denied by you: the turn stopped" after a reload too**, not
97
+ "✕ Step interrupted": the OpenCode spoke marks the timeline's error row `declined: true` only in OpenCode's own
98
+ shape (a tool call failed `{type: "aborted", message: "The user declined this tool call"}` and the step
99
+ `{type: "aborted"}`, so a tool throwing that text cannot hide a real error), and the console names who denied it
100
+ from the agent audit (loaded once for such a turn; only a deny between that turn's start and the next turn's
101
+ counts), else "Denied: the turn stopped". A new turn denied in a session that already shows a stored denied turn
102
+ keeps its own note.
103
+ - **MCP argument errors read `invalid: …` in every tool family** (teams, team intake, artifacts, artifact jobs,
104
+ watchlist, profiles and the core tools share one `ToolArgError`), never `<tool> failed: …`.
105
+ - **Team staffing points a misplaced `key` at `idempotencyKey`**: `placement{key}` / `end{key}` in `team_staff` /
106
+ `team_replace` answer "did you mean idempotencyKey (the Idempotency-Key header)?" (staffing sets `launch.key`
107
+ itself), not "key (top level)".
108
+ - **Tool-call details with a screenshot open again** (M1). OpenCode keeps tool screenshots (`read` of a PNG, MCP
109
+ image results) as `data:` URLs inside the tool output, so their bytes counted against the ~750 KB detail limit and
110
+ a large one failed with "Timeline part is too large to display". The spoke now replaces each embedded image
111
+ (`data:…;base64,…` URLs anywhere in the detail, raw base64 image parts) with a placeholder such as
112
+ `[image/png, 1.2 MB — not shown]` before the limit is checked, so the input and text output show; the hub and the
113
+ console do the same for details from older spokes, so no image bytes reach the console in a tool detail. The limit
114
+ still holds for huge text. Showing images inline is a later release.
115
+
116
+ ## 0.8.1
117
+
118
+ A fix-up release after 0.8.0. App and gateway messages keep waking idle sessions (per-conversation hops with a relay
119
+ guard, and the reason when a wake is skipped); launches say why they wait; the Concierge gets per-machine Directory
120
+ and Model dropdowns, full-detail Decision cards (tap-tier ones too, never by voice), plainer Activity and fail-closed
121
+ hardening; the console uses in-page dialogs, collapsible sidebar sections and names instead of raw IDs; the right
122
+ rail's Branch and Changes work for background-service sessions; the hub state dir honours `XDG_STATE_HOME` without
123
+ moving existing state; MCP gains profile lifecycle tools. No migration is needed. Codex and Claude Code fork/revert
124
+ remain for a later release.
125
+
126
+ ### MCP: profile lifecycle and team staffing
127
+ - **Profile lifecycle over MCP** (`hub mcp`, `POST /mcp`): `profile_show`, `profile_versions`, `profile_create`,
128
+ `profile_update`, `profile_version_create` (content or `fromVersion` rollback), `profile_skill_set` /
129
+ `profile_skill_remove`, `profile_command_set` / `profile_command_remove` (attach or detach one skill or command as
130
+ the next immutable version), `profile_archive` / `profile_restore` and `profile_assign` / `profile_unassign`. Each
131
+ is an existing `/api/agent/profiles…` route with its scopes (`profiles:read`, `profiles:manage`,
132
+ `profiles:assign` + `sessions:read`); no server change ([docs/profiles.md](docs/profiles.md#sdk-and-mcp)).
133
+ - **`team_staff` / `team_replace` describe the nested launch spec** (`workspace{repo, mode, branch, base, remote}`,
134
+ `placement{machine, labels, mode}`, `runtime{kind, profile}`, `end{when, timeout, cleanup}` and the top-level
135
+ fields) in their input schema ([docs/teams.md](docs/teams.md#mcp-tools)).
136
+ - **Launch specs name misplaced fields**: an unknown launch field answers every unknown field at once and where each
137
+ belongs (`Unknown launch fields repo, cleanup: did you mean workspace.repo, end.cleanup?`), with the nested shape
138
+ as the hint, for `POST /api/agent/launches`, team staffing and the MCP tools.
139
+
140
+ ### Messaging and Concierge settings
141
+ - **Settings → Concierge: Directory and Model are dropdowns of the picked machine.** Directory lists the projects its
142
+ OpenCode service reports under (or at) its node's workspace roots, then the other roots, with **Other folder…** for
143
+ a typed path (a project that is the root itself is listed once, as a project, and the hint names the roots: FX-11);
144
+ picking another machine clears a directory that machine does not report. Model lists the machine's OpenCode models
145
+ with **Machine default** and **Other model…**; picking another machine resets a model its list lacks. Loading,
146
+ offline and error states keep the typed fallback; hints are linked to the dropdowns (`aria-describedby`). New
147
+ user-only route `GET /api/agent/concierge/machines/:id/models` (404 for a machine that is not yours, 503
148
+ `offline` without a connected service, a reported session, or a spoke answer).
149
+ - **App asks stop waking sessions after 4 round trips (S1, wge0vjdz).** An app token was one hop-depth party for all
150
+ its conversations and had no human turn to reset it, so every ask/reply round trip raised it by 2 and from the 5th
151
+ ask on (hop 9 > the cap of 8) nothing it sent woke anyone for 24 h; the spoke answered a bare `queued`. Apps now
152
+ count hops per conversation: a new `msg`/`ask` from an app is hop 1, replies and declines to an app raise nothing,
153
+ and an app's own reply or decline is the hop of the message it answers + 1 (reply ping-pong still hits the cap).
154
+ Session-to-session chains, human-turn resets, the pair limit (20/10 min) and the wake budget (30/10 min, which
155
+ bound an app answering one session with new messages forever) are unchanged. Covers app tokens, `app:concierge`,
156
+ the workspace coordinator and team intake.
157
+ - **Relay guard for apps.** For 5 minutes (the relay window, a code-level default, not a config setting) after a session sends an app something, the app's new
158
+ msg/ask to *another* session carries that hop + 1, so a relay loop A → app → B → app → A reaches the hop cap after
159
+ about 8 hops instead of only the rate limits; spaced-out asks and one-to-one app conversations still start at hop
160
+ 1. The hub's own parties (Concierge, workspace coordinator, team intake) keep no relay depth: they send on events
161
+ and submissions, not on what they receive.
162
+ - **Team intake continues the submitter's chain.** A session's submission is delivered from `app:team-intake` at
163
+ the submitter's depth + 1 (kept while it is queued), not hop 1.
164
+ - **Hop-cap alert wording.** "Agent messages went 8 hops without you (latest: A → B) …" instead of "Sessions
165
+ messaged each other", which was wrong when an app was in the chain.
166
+ - **Never give agents an app token** (README): it lets them send at a fresh hop depth.
167
+ - **Why a message did not wake.** `POST /api/agent/msg|ask|reply|decline` answers carry `woke` and, when false,
168
+ `wakeSkipped: wake_false | subagent | hop_cap | budget` (additive; SDK `Sent.woke`, `Sent.wakeSkipped`, type
169
+ `WakeSkip`, and `wakeSkipNote`/`WAKE_SKIP_TEXT` for the shared wording). `hub msg`/`ask`/`reply`/`decline`, the
170
+ MCP tools and the OpenCode `hub` tool (text and metadata) print it: `queued (read at its next turn) — wake
171
+ skipped: hop limit reached (8 agent messages without a human turn)`; `wake_false` reads "sent without a wake".
172
+
173
+ ### Launches say why they wait
174
+ - A queued launch stores why it waits: `GET /api/agent/launches/:id` carries `waiting: {wait, reason, since}` and
175
+ `reason` is the same text. Written only when it changes, announced as a `launch` event and logged once per change
176
+ (`[hub] launch <id> waits (<wait>): <reason>`); cleared once placed. Before, it said only "Queued".
177
+ - Specific reasons: the active cap names who holds it (`4 of 4 active launches: 3 by app "ci-bot", 1 by you`), a
178
+ full node gives its counts (`baruch (4/4 used, 0 reserved)`), a node that does not fit its first problem.
179
+ - `hub launches list|show` and the MCP launch tools print the wait; Settings → Agents & access has a new **Launches**
180
+ list (live launches, updated from the event stream, why each waits, Stop with a per-row accessible name; the
181
+ Concierge's own launch is managed in Settings → Concierge); the Concierge's status says "Waiting: …" instead of
182
+ "Starting".
183
+ - An app or session sees its own launch's wait in a launcher-safe form: counts only for the cap ("1 yours, 3 by
184
+ others"), no machine names. The user sees the full text.
185
+ - Only a new wait kind is a launch change (journal `launch.changed` with `data.wait`, SSE); never a webhook, so
186
+ webhooks keep one delivery per state (`launch.queued` once). Text-only updates (slot counts) go to the console's
187
+ live stream only; artifact jobs show them without a new job revision.
188
+ - The Concierge's own launch no longer counts toward `agents.launches.maxActive` (it still takes a node slot).
189
+ - Revoking an app token does not stop its launches; they are flagged (`launcherRevoked`) in the user's lists and in
190
+ the cap's waiting reason so the user can stop them. Launches record the app token's label (`byLabel`).
191
+ - A node cleanup that failed while the node stayed connected is retried (capacity reports, the 15 s sweep, up to 5
192
+ times; and on reconnect), so the node no longer keeps a slot for an ended launch.
193
+ - A running launch ends `lost` when the same node instance that reported it leaves it out of its reports for 5
194
+ minutes while no spoke reports its session, freeing its reserved slot. Never on a disconnected node, a short gap,
195
+ or a restarted node daemon (a new instance) that never reported it; a lost launch whose session is still reported
196
+ keeps its workspace. `agents.launches.lostAfterMinutes`: 0–1440, or 0/false to turn it off.
197
+ - A launch stopped between placement's placing and provisioning steps (e.g. the Concierge reaping an orphan) is
198
+ skipped quietly instead of logging a placement error.
199
+ - Engine timers are clamped to setTimeout's 32-bit limit; an `end.timeout` over 24.8 days (up to 30) no longer fires
200
+ at once.
201
+
202
+ ### Concierge hardening
203
+ - **Activity in plain words.** Settings → Agents → Activity says what the Concierge did without internal terms:
204
+ "Approved by the Concierge inside your grant. Its note: «…»", "Needed your tap: shell commands, outside folders
205
+ and tools always do", "The Concierge proposed this for your answer", "Refused: only you can message the
206
+ Concierge". Its note is framed as data (its own « » become ‹ ›, bidi and invisible controls dropped, one line, at
207
+ most 300 characters), so it cannot fake text after it.
208
+ No `inGrant`, `auto` or raw Decision IDs; the Concierge is told to write its why in plain words too
209
+ (Concierge prompt v7).
210
+ - **Decision lookups in the Concierge panel** skip IDs in a reply that is still streaming (half an ID was looked up
211
+ and remembered as unknown), back off on 429/5xx/network failures (2 s doubling, ≤ 1 min) instead of retrying on
212
+ every render, and run at most 4 at once.
213
+ - **Watchlist after a reconnect.** A process counts as reporting its sessions only after its first sessions frame
214
+ (catalog `sessionsPending`, additive), so the watchlist and board no longer empty for a moment after `hello`.
215
+ - **`concierge_find`** with no letters or digits ("@", "?!") no longer lists every session: the hub's
216
+ `GET /api/agent/sessions?q=` matches nothing for such a `q` (a blank `q` still filters nothing).
217
+ - **Concierge session lock file** (`concierge-sessions.json`): writes from several OpenCode processes are serialized
218
+ under an exclusive `<file>.lock` (a stale one is broken), so no process drops another's locks.
219
+ - **Identity:** a Concierge session older than the 500 the hub keeps still counts as one while its machine reports
220
+ it locked (spokes mark locked sessions `locked: true` from their first sessions frame after a restart). Concierge
221
+ identity reads (route gate, manage guard, acting guard, inbound/outbound rules, team staffing, Decision and audit
222
+ labels) wait at most 5 s, and a user whose read timed out is answered at once for the next 5 s; a stuck or failed
223
+ read refuses with a neutral 503 `busy` "Hub state is busy, try again" (new error code, CLI exit 7), audited as
224
+ decision `busy`, outcome `refused`; team staffing
225
+ (attach, replace, a staffing launch) refuses a Concierge session in the service as well as the route.
226
+ - **Arm badge:** the console lapses an arm at its expiry itself, so a missed `armed: null` never leaves a stale ✨.
227
+
228
+ ### Console polish
229
+ - **Collapsible sidebar sections**: Needs you, Running, Pinned, the session groups, Recents and Workspaces collapse
230
+ from their heading (mouse or keyboard, `aria-expanded`), remembered on this device. A collapsed section shows its
231
+ count; Needs you always shows its count, so collapsing it never hides that something waits.
232
+ - **In-page confirm dialog** instead of the browser's `window.confirm` (Revert, stopping a launch, turning the Concierge off, revoking app tokens, grants,
233
+ machines and devices, turning agent actions off, signing out, admin user actions, resetting speech settings):
234
+ focus starts on the primary action (Enter confirms), on Cancel for destructive (red) actions; Tab stays inside; a
235
+ held Enter (key auto-repeat) is ignored; Escape or Cancel cancels (Escape closes only the dialog, not the
236
+ Concierge panel or a popover behind it); 44 px buttons on touch screens.
237
+ - **/new**: a machine that reports exactly one project has it chosen; Enter without a project says so and moves the
238
+ focus to the Project chip, which stands out while nothing is chosen.
239
+ - **Concierge panel** is fitted with the sidebar and rail: at 1280 px the transcript keeps 420 px (it had 345); at
240
+ 1024 px it gets 304 px with every panel at its minimum (it had 119), 420 px without a rail.
241
+ - Fixes: a path with a leading dot in the Changes list (`.claude/settings.local.json`) no longer shows the dot at the
242
+ end; "Denied by you" shows once when a denial stops the turn; a grant's expiry rounds to the nearest whole day
243
+ everywhere (no more "7 d" on one page and "6 d" on another); Decision links in the Concierge panel no longer push
244
+ the text after them to a new line; the panel's disabled Send button looks disabled.
245
+ - **The right rail's Branch and Changes come from the session's own directory** (FX-10). OpenCode's plugin `vcs`
246
+ API answers for the plugin instance's own location whatever location is passed, and the spoke used the first
247
+ instance's (the background service's is its `$HOME`), so a service session in a project showed "No changes." and
248
+ Branch "—" (and the per-file diff read the wrong tree). The spoke now reads through the plugin instance whose
249
+ location is the session's directory, else the own server's API (which honours the location), else shows nothing;
250
+ a read that takes over 5 s (a hung server) is left out, so the rest of the summary still answers.
251
+
252
+ ### Robustness and the hub state directory
253
+ - **State dir honours `XDG_STATE_HOME`, sticky**: the plugin (launchers, Concierge lock), the local hub, the hub
254
+ server and the Claude Code and Codex spokes resolve it the same way: `$HUB_STATE_DIR`; else `$XDG_STATE_HOME/hub`
255
+ if it exists; else an existing `~/.local/state/hub`; else `$XDG_STATE_HOME/hub` when XDG_STATE_HOME is set (fresh
256
+ installs); else `~/.local/state/hub`. The legacy `opencode-hub` / `opencode-voice` state migrates from under
257
+ `$XDG_STATE_HOME` and `~/.local/state`. `hub claude-code install` / `hub codex-spoke install` carry
258
+ `XDG_STATE_HOME` into the service. The Concierge lock is read from both default places (written to the resolved
259
+ one), so a session locked before the upgrade stays locked; with `HUB_STATE_DIR` set (an isolated preview or test)
260
+ it is read and written only there.
261
+ **Upgrade note:** nothing moves. If `XDG_STATE_HOME` is set and you have `~/.local/state/hub`, the hub keeps using
262
+ it and logs one line at startup with the command to switch: stop the hub and OpenCode, then
263
+ `mv ~/.local/state/hub "$XDG_STATE_HOME/hub"`. Isolated test/preview instances must set `HUB_STATE_DIR` and their
264
+ own `HOME`; `XDG_STATE_HOME` alone does not isolate them from an existing `~/.local/state/hub`.
265
+ A dir counts only when it holds hub state (`state.json`, `token-keys.json`, `agent/`, `blobs/`,
266
+ `concierge-sessions.json`, `bin/`, `hub.log`, `hub.pid`, `claude-code/`, `codex/`), never `bootstrap/` alone, and
267
+ `hubctl setup` now writes `bootstrap/` into the dir the hub resolves (its state root follows the resolver; other
268
+ roots unchanged). The legacy `opencode-hub` / `opencode-voice` copy also fills a hub dir that holds no hub state
269
+ yet (e.g. only `bootstrap/` from an earlier setup), next to `bootstrap/` and never over it. A relative
270
+ `XDG_STATE_HOME` is ignored (XDG spec). **Service units must carry the same `XDG_STATE_HOME` as your shell** (or `HUB_STATE_DIR`), or a
271
+ shell and a unit can use different dirs: hub's own `claude-code` / `codex-spoke` units already pass it; a unit you
272
+ wrote for `opencode serve --service` needs it set.
273
+ - **Warmer hardening** (`hub node`): OpenCode's `service.json` is used only when it is a regular file owned by this
274
+ user and not group/world-writable (no symlinks), and its pid is this user's live process (EPERM is not ours). Warm
275
+ requests wait 30 s by default (was 10 s), configurable with node.json `opencode.timeoutSeconds` (1–300).
276
+ - `hubctl setup --verify-launch` warns about a stored plain-http (non-loopback) login, like every `hub` command.
277
+ - Refused hub URLs (`hub login`, `hubctl machine enroll`) no longer echo user info: shown as `https://***@host`
278
+ (also `http:/u:p@h`, `http:u:p@h`, `http:\\u:p@h`, leading whitespace and scheme-less `user:pass@host`).
279
+ - **Listings are cancelled**: a session listing that times out aborts its signal (`SpokeHost.listRecent` gets
280
+ `signal`, passed to every OpenCode request), so a hung server does not collect abandoned listings.
281
+ - **Pending messages tell their sender when they expire**: a message still pending after 24 h (recipient offline or
282
+ busy) gets delivery `"expired"` (new `MessageDelivery` value), its sending session gets a quiet hub notice
283
+ (`wake: false`, never the text), the user an alert (push kind `message`), and the change is journaled
284
+ (`message.changed`).
285
+ - `HEAD /healthz` and `HEAD /readyz` answer like GET (same status and headers, no body) instead of 404, for uptime
286
+ monitors and `curl -I`. No other route changed.
287
+ - Docs: README "Testing and preview isolation" (`HUB_STATE_DIR`, own `HOME`/`XDG_*`, `herdr: false`,
288
+ `autoStartLocalHub: false`, sessions only in scratch repos).
289
+
290
+ ### Decision cards and the Concierge panel
291
+ - **Decision cards say what they ask for** (FX-05): the Concierge panel's armed and listed cards, and every Decision
292
+ card on Home and in sessions, show the requested action as a session's own permission card does: the permission
293
+ kind, the exact command, paths or patterns (the session's live request when the console has it), who asked, why it
294
+ is outside the grant and the stated purpose. A Concierge proposal shows its verb ("Send a prompt as you", "Stop the
295
+ session", …), the target session, the exact prompt or form answer, and the Concierge's why; its question heads the
296
+ card without repeating the body. A plain ask about a session that waits on one permission shows that request.
297
+ Long commands and prompts wrap and are cut with **Show all** (a session's permission card too). All of it is
298
+ rendered as plain text, from the user's own console data: the Concierge's board and update lines still carry no
299
+ blocker summaries or Decision text.
300
+ - **The card says where its detail comes from** (FX-05): "Live request" (the session's pending request), "The hub's
301
+ record of the request" (new structured fields on Decisions: `action.request {action, resources, save?}` and
302
+ `action.reason` on escalations, `action.request` on deny proposals; bounded to 20 × 2,000 characters), or "From
303
+ the request text" for older Decisions, now parsed without splitting a command at ", " or at a ". Outside its
304
+ grant: " inside it. Bidi and invisible characters in commands, paths and prompts show as markers ("⟨U+202E⟩"),
305
+ also on a session's permission card (tag characters U+E0000–E007F, separators, fillers too; a valid subdivision
306
+ flag and ZWJ emoji stay intact); text is cut by grapheme; **Show all** buttons name their field and control the
307
+ text they open. What the hub did not store is recorded with the request (`resourcesTotal`, `savesTotal`, `cut`
308
+ [{field, index, length}], `truncated`) and shown on the card ("… cut at 2,000 characters (of 3,017)", "+10 more
309
+ items not stored", "The hub received this request already shortened"), so a shortened command never reads as
310
+ complete. The structured request and reason are for the user's console only: the Concierge's Decision reads
311
+ (`/api/agent/concierge/decisions`, `concierge_decision`) return the records exactly as before.
312
+ - **Tap-tier Decisions in the Concierge panel** (FX-05b): the panel lists the Concierge's tap-tier Decisions (shell,
313
+ outside folders, tools, prompt/answer proposals) with the same detail, answered by a tap on the card. They are
314
+ never armed and never answered by voice or text; the button's badge and the panel's list now agree.
315
+ - **Fix: Decisions and the armed card no longer vanish at random** (quirk pgd96twg): the open list and the armed
316
+ Decision reloaded on every stream open replaced what the stream had said while the reload was out. A reload's
317
+ snapshot now merges the Decision events that came meanwhile (and an older reload never overrides a newer one),
318
+ and an arm event that came while `GET …/arm` was out wins over its answer. A load out for over 30 s is forgotten
319
+ (no events buffered forever), and an older `GET …/arm` never overrides a newer one.
320
+
321
+ - **Sessions by name, never a raw ID** (FX-12): Decision cards (their heading, "from", Requested by and Session), a
322
+ session's permission card (the recommender, who answered), hub message rows, Settings → Agents & access Activity
323
+ and Settings → Concierge's watch and mute lists name a session by its @name (the hub directory), else its title,
324
+ else its project folder and a short ID ("hub · ses_e6256f…"); the full ID is in the tooltip. An escalation's
325
+ heading is built from the hub's structured request ("API refactor asks to approve shell"; the command and reason
326
+ are in the details), other hub-written questions have their `ses_…` IDs named. Activity says "a permission
327
+ request", "a form" and "grant", with the IDs on hover. Display only: the data, the hub and what the Concierge
328
+ model sees are unchanged.
4
329
 
5
330
  ## 0.8.0
6
331
 
package/README.md CHANGED
@@ -480,6 +480,11 @@ cookie and a hub bearer token (400).
480
480
  | `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
481
481
  | `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
482
482
 
483
+ Sends to a session (`msg`, `ask`, `reply`, `decline`) answer `{message, delivery, woke, wakeSkipped?}`: `woke` says
484
+ whether the hub sent it to wake the recipient, and when not, `wakeSkipped` says why (`wake_false`, `subagent`,
485
+ `hop_cap`, `budget`; see **Limits**). The CLI and MCP tools print it, e.g. `queued (read at its next turn) — wake
486
+ skipped: hop limit reached`. Hubs before 0.8.1 send neither field.
487
+
483
488
  The act verbs, grants, kill switches and the agent audit are in [Agent actions and grants](#agent-actions-and-grants).
484
489
 
485
490
  **Decisions and `@me`.** `@me` addresses the user: `msg @me` is stored in the user's inbox (push kind `message`);
@@ -536,7 +541,7 @@ gets a model call of its own: an idle session reads it with its next turn; a bus
536
541
  ends (until then the hub keeps it `pending`), so it too arrives with the next turn. Urgent `--no-wake` messages,
537
542
  replies and asks to a busy session that is not woken (a subagent, past the hop cap or wake budget) go into the running
538
543
  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
544
+ offline gets it when it comes back (pending, for 24 hours; after that it expires undelivered, `expired`, and its sender is told: a session by a quiet hub notice, you by a notification). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
540
545
  `unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
541
546
  after waits of 5, 15 and 45 minutes in turn, then you are notified. Messages to you and to apps are stored in an inbox (`hub inbox`).
542
547
 
@@ -576,8 +581,21 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
576
581
 
577
582
  **Limits.** Per 10 minutes: 20 messages per sender→recipient pair and 300 per user (`rate_limited`, exit 6, with
578
583
  `retryAfter`), and 30 turns started per recipient (more messages are still delivered, without waking it). A chain of
579
- replies between agents may run 8 hops without a human turn; past that, messages are delivered without waking and
580
- you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. You can switch
584
+ messages between sessions may run 8 hops without a human turn; past that, messages are delivered without waking and
585
+ you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. Apps (an app
586
+ token, and the hub's own Concierge, workspace coordinator and team intake) count hops per conversation: a new `msg`
587
+ or `ask` from an app starts at hop 1 and replies to an app do not count against it, so an app's 5th (or 50th) ask
588
+ still wakes the session it talks to. Two guards keep apps from restarting chains: a reply or decline counts up from
589
+ the message it answers (reply ping-pong through an app is capped like any other chain), and for 5 minutes after a
590
+ session sends an app something, the app's new messages to *other* sessions continue from that hop (a relay A → app →
591
+ B → app → A reaches the cap after about 8 hops; spaced-out work starts at hop 1 again). An app answering one session
592
+ with new messages forever is held by the pair limit and the wake budget. A team intake submitted by a session
593
+ continues that session's chain. A send's answer says when and why it did not wake (`woke`, `wakeSkipped`); the CLI,
594
+ `hub mcp` and the OpenCode `hub` tool print it.
595
+
596
+ **Never give agents an app token.** A session that holds one (in its environment, an MCP config, a gateway) can send
597
+ as the app, at a fresh hop depth, outside its own chain and its session-scoped policy. Sessions message with their
598
+ own session token (the `hub` tool, `hub msg` in their shell); app tokens are for scripts and outside tools. You can switch
581
599
  messaging off for your account (`PUT /api/agent/messaging {enabled: false}`): sends then answer `paused` (exit 5),
582
600
  reading still works.
583
601
 
@@ -658,11 +676,12 @@ allow patterns (else `needs_user`) → the hourly cap → the kill switches agai
658
676
  | `gone` | 410 | 8 | answered elsewhere first, expired or ended |
659
677
  | `unsupported` | 422 | 7 | the session's spoke cannot do it (e.g. an agent's prompt without `hubPrompt`) |
660
678
  | `offline` | 503 | 7 | no spoke reports the session now |
679
+ | `busy` | 503 | 7 | the hub's state did not answer in time (`retryAfter`): refused, try again |
661
680
 
662
681
  **Escalation.** An approval outside the grant answers `needs_user` with the evaluation (`verdict`, `reason`, the
663
682
  grants checked, and a `tier`) and opens a Decision for you: one per agent and request (asking again updates its
664
683
  reason), with Approve once (or Approve always, when asked), Deny (safe), and the agent's `--why` as its
665
- recommendation. The hub sets the tier, never the agent: `tap` (answered by a tap in the console, not from a
684
+ recommendation (the Concierge's only with `recommend: true`; a Concierge without a grant also escalates). The hub sets the tier, never the agent: `tap` (answered by a tap in the console, not from a
666
685
  notification) for shell, `external_directory`, MCP and unknown tools and for anything near a never rule; `confirm`
667
686
  otherwise. Your answer replies to the permission **as you** (no grant or kill switch applies to you); if the request
668
687
  was answered elsewhere or ended first, the Decision closes and the agent's next try is `not_found`. A request's
@@ -710,7 +729,7 @@ and the SDK. Only you manage grants: the console (Settings → Agents) or the CL
710
729
  | `GET` · `PUT /api/admin/agents/acting {enabled}` | admins | – | – |
711
730
  | `GET /api/agent/audit?limit=&before=` | ✓ | – | – |
712
731
 
713
- **Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`) is in
732
+ **Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`, `busy`) is in
714
733
  your agent audit with who acted, the verb, the session and request, the grant, the reason and the outcome (`ok`,
715
734
  `gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`); so are your answers to escalations, grant
716
735
  requests and switch changes. Read it in Settings → Agents → Activity or with `GET /api/agent/audit` (newest first,
@@ -746,11 +765,17 @@ those harnesses through their service spokes ([docs/launches.md](docs/launches.m
746
765
  spec's fields and limits to placement, caps, the node's git rules, webhook signatures and sandboxed launches in
747
766
  OpenShell (`hub node openshell check`): [docs/launches.md](docs/launches.md).
748
767
 
768
+ A queued launch says why it waits (`hub launches list`, `GET /api/agent/launches/:id` `waiting`, Settings → Agents &
769
+ access → Launches): the per-user cap `agents.launches.maxActive` (default 4) with who holds it, a full node with its
770
+ used/reserved slots, or each node's first problem. The cap counts every launcher's live launches (apps, sessions,
771
+ you), except the Concierge's own; revoking an app token does not stop its launches, they are flagged so you can stop
772
+ them ([docs/launches.md](docs/launches.md#how-the-caps-count)).
773
+
749
774
  ## hub CLI
750
775
 
751
776
  `hub` (also installed as `hubctl`) reads, names, messages and acts on your sessions through the hub's [`/api/agent` API](#agent-identity-agent-api),
752
- for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`$HUB_STATE_DIR/bin`, default
753
- `~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
777
+ for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`bin` in
778
+ the hub state dir, see `HUB_STATE_DIR` under [Configuration](#configuration): usually `~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
754
779
  session `hub` just works. A launcher runs the CLI bundle shipped in this package (`packages/cli/dist/hub-cli.js`)
755
780
  with the OpenCode binary itself (`BUN_BE_BUN=1 exec <opencode> hub-cli.js "$@"`), so neither bun nor node has to
756
781
  be installed; without Bun it falls back to `node`. The launchers are rewritten only when their content changes.
@@ -758,7 +783,7 @@ When a standalone hubctl is installed as `~/.local/bin/hubctl` (what `hubctl set
758
783
  instead, so shells, services and MCP clients all use that one signed program. For your own terminals the plugin also
759
784
  links `~/.local/bin/hub` to its `hub` launcher, with the setup's rule for `~/.local/bin`: only when nothing is there or
760
785
  it is hub's own link, never when another `hub` is on PATH (then it prints where hub's is), and only from the default
761
- state dir (not with `HUB_STATE_DIR`). `hubctl setup`'s `hub` → `hubctl` link is kept; `~/.local/bin/hubctl` is left to
786
+ state dir (never with `HUB_STATE_DIR`). `hubctl setup`'s `hub` → `hubctl` link is kept; `~/.local/bin/hubctl` is left to
762
787
  `hubctl setup`. When `~/.local/bin` is not on PATH it prints one hint.
763
788
 
764
789
  **Name clash:** GitHub's old `hub` CLI (github/hub) uses the same name. In hub sessions ours comes first on PATH;
@@ -924,7 +949,7 @@ is set and the credential is not that session. The CLI removes `BUN_BE_BUN` from
924
949
  | 4 | `needs_user` |
925
950
  | 5 | `forbidden`, `paused` |
926
951
  | 6 | `rate_limited` (with `retryAfter`) |
927
- | 7 | `unsupported`, `offline` |
952
+ | 7 | `unsupported`, `offline`, `busy` |
928
953
  | 8 | `gone`; an `ask --wait` that expired |
929
954
  | 9 | `unauthorized`, `token_expired` (a session token: start a new shell), or not logged in |
930
955
 
@@ -944,7 +969,10 @@ carry the same canonical compact events as `events`; their signing secret appear
944
969
  `answer` (`answer` object or `cancel`), `prompt` (`delivery`) and `stop`, and the launch tools `launch` (`repo`
945
970
  required and absolute; `mode`, `branch`, `base`, `remote`, `key`, `name`, `roles`, `harness`, `agent`, `model`,
946
971
  `runtime`, `profile`, `machine`, `nodeLabels` and `labels` objects, `prefer`, `prompt`, `end`, `timeout`,
947
- `cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`), with the same arguments
972
+ `cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`); the profile tools (`profiles`,
973
+ `profile_show`, `profile_create`, `profile_version_create`, `profile_skill_set`, `profile_command_set`,
974
+ `profile_assign`, …; [docs/profiles.md](docs/profiles.md#sdk-and-mcp)), the workspace, artifact and team tools
975
+ ([docs/teams.md](docs/teams.md#mcp-tools); `team_staff` takes the nested launch spec), with the same arguments
948
976
  (`to`, `text`, `addr`, `id`, `requestID`, `formID`, `why`, `n`, `wake`, `urgent`, `all`, `project`, `role`, …). The act
949
977
  tools need an app token with scope `sessions:act` and a grant, the launch tools scope `sessions:launch` and a
950
978
  launch grant; `needs_user` results include the evaluation. `ask` takes `wait` of at
@@ -1180,7 +1208,7 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
1180
1208
  | `HUB_AUTH` | `pairing` | `oidc` for several users signing in with Google; see [Multi-user](#multi-user-sign-in-with-google) |
1181
1209
  | `HUB_HOST` / `HUB_PORT` | `127.0.0.1` / `4097` | listen address |
1182
1210
  | `HUB_PUBLIC_URL` | `http://<host>:<port>` (`http://127.0.0.1:<port>` when the host is `0.0.0.0` / `::`) | origin used in links, cookies and CSRF checks |
1183
- | `HUB_STATE_DIR` | `~/.local/state/hub` | `state.json` (0600): users, machines and devices (token hashes only) with their owners, VAPID keys, push subscriptions, speech settings (server default and per user; provider API keys set from the phone), the audit log (last 500 entries); `token-keys.json` (0600): the HMAC keys that sign session tokens (created on first use, older keys kept so their tokens still verify); `agent/` (0700, files 0600, created on first use): agent data such as messages and name claims, one JSON file per user and collection, written shortly after each change and on shutdown |
1211
+ | `HUB_STATE_DIR` | `$XDG_STATE_HOME/hub` if it holds hub state (`state.json`, `token-keys.json`, `agent/`, `bin/`, … — a `bootstrap/` alone does not count), else an existing `~/.local/state/hub` holding hub state (kept after an upgrade: the hub logs the `mv` to switch), else `$XDG_STATE_HOME/hub` when XDG_STATE_HOME is set (fresh installs), else `~/.local/state/hub`; the plugin, the local hub, the hub server and the Claude Code and Codex spokes all resolve it the same way | `state.json` (0600): users, machines and devices (token hashes only) with their owners, VAPID keys, push subscriptions, speech settings (server default and per user; provider API keys set from the phone), the audit log (last 500 entries); `token-keys.json` (0600): the HMAC keys that sign session tokens (created on first use, older keys kept so their tokens still verify); `agent/` (0700, files 0600, created on first use): agent data such as messages and name claims, one JSON file per user and collection, written shortly after each change and on shutdown |
1184
1212
  | `HUB_TOKEN_KEYS` | | overrides `token-keys.json` with the same JSON, `{"active": "<kid>", "keys": [{"kid": "<kid>", "secret": "<base64 of ≥ 32 random bytes>", "created": <ms>}]}`, e.g. a secret shared by several hub instances; the file is then never written |
1185
1213
  | `HUB_DATABASE_URL` / `HUB_VALKEY_URL` | | cluster mode: several hub pods sharing Postgres and Valkey (both or neither; requires `HUB_AUTH=oidc` and `HUB_TOKEN_KEYS`); see [docs/cluster.md](docs/cluster.md) |
1186
1214
  | `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
@@ -1208,7 +1236,8 @@ recent activity.
1208
1236
  the Docker image, npm package and binaries ship the build). One store fed by the hub's SSE stream drives:
1209
1237
 
1210
1238
  - **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
1211
- terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
1239
+ terminal grouped by project (or server) with `+`, Recents. Each section collapses from its heading (remembered on
1240
+ the device, `hub.sidebar.sections.collapsed`), showing its count; Needs you always shows its count. Rows show status, a pulsing green dot left of the
1212
1241
  title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
1213
1242
  (rename, pin, archive, stop, Allow/Disallow Voice).
1214
1243
  - **Primary navigation** above the session groups: **All sessions**, **Artifacts** (the library across workspaces,
@@ -1339,7 +1368,8 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
1339
1368
  environment and folder are remembered on the device (`localStorage` `hub.newSession`); the remembered folder is
1340
1369
  preselected only while that machine still reports exactly that folder, and nothing else is guessed: otherwise the
1341
1370
  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
1371
+ (CON-12). A machine that reports exactly one project has it chosen; Enter without a project says so and moves the
1372
+ focus to the Project chip (FX-06). The group "+" preselects with `?machine=&key=&dir=`. "Opened in Herdr · <workspace>" confirms the new tab; if Herdr
1343
1373
  failed, the session still exists and the hub's `warning` is shown. 409 (background service not
1344
1374
  connected) is shown under the composer.
1345
1375
 
@@ -1836,7 +1866,7 @@ in the file).
1836
1866
  | `auth` | `{mode, issuer?, clientId, clientSecret, allowed, admins}`; see [Multi-user](#multi-user-sign-in-with-google) |
1837
1867
  | `cluster` | `{database, valkey}` URLs for cluster mode (env `HUB_DATABASE_URL` / `HUB_VALKEY_URL` win); see [docs/cluster.md](docs/cluster.md) |
1838
1868
  | `agents.never` | permission patterns (`action[:glob]`) no agent may ever approve, for every user, checked before any grant; see [Agent actions and grants](#agent-actions-and-grants) |
1839
- | `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60); see [docs/launches.md](docs/launches.md#placement-and-caps) |
1869
+ | `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60) and `lostAfterMinutes?` (0–1440 or false, default 5: a running launch the same node instance stops reporting, with its session gone, ends lost; 0/false: never); see [docs/launches.md](docs/launches.md#placement-and-caps) |
1840
1870
  | `agents.webhooks.allowPrivate` | `true` lets webhooks target `http://`, loopback and private hosts (self-hosted receivers); see [docs/launches.md](docs/launches.md#where-webhooks-may-go) |
1841
1871
 
1842
1872
  Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
@@ -1876,8 +1906,11 @@ When the configured server is loopback (`localhost`, `127.0.0.1`, `[::1]`, http
1876
1906
  on it, the plugin starts `<this repo>/bin/hub.ts` as a detached background process bound to that
1877
1907
  address and port. The runtime (`resolveHubRuntime` in `src/spoke/localhub.ts`): this process when it is
1878
1908
  `bun`, else `bun` on `PATH`, else `~/.bun/bin/bun`, else the current executable with `BUN_BE_BUN=1`
1879
- (inside OpenCode that is the `opencode` binary, a compiled Bun, so nothing else needs installing). Output is appended to `~/.local/state/hub/hub.log` (`$HUB_STATE_DIR`
1880
- if set; a legacy `~/.local/state/opencode-hub`, else `~/.local/state/opencode-voice`, is copied there first). It happens on setup (**On this
1909
+ (inside OpenCode that is the `opencode` binary, a compiled Bun, so nothing else needs installing). Output is appended to `hub.log` in the hub state dir (`$HUB_STATE_DIR`, else as in the
1910
+ `HUB_STATE_DIR` row of the configuration table, usually `~/.local/state/hub`). When that dir holds no hub state yet (missing,
1911
+ empty, or only `hubctl setup`'s `bootstrap/`), the newest legacy state dir is copied there first (next to
1912
+ `bootstrap/`, never over it): `opencode-hub`, then `opencode-voice`, under `$XDG_STATE_HOME` and
1913
+ then under `~/.local/state` (never with `HUB_STATE_DIR`). It happens on setup (**On this
1881
1914
  computer**, any process), on **Open hub** (even with auto-start
1882
1915
  off), and when an enrolled connection is refused:
1883
1916
  the shared service process (`--service`) tries at once, other processes after 8 s so the service goes
@@ -2073,6 +2106,42 @@ workflow). Publish it by hand after `@huynle/hub` of the same version is on npm:
2073
2106
  `@huynle/opencode-hub` was never published, so it has no shim. A new package name needs one manual first
2074
2107
  publish before trusted publishing can take over (see the comment in `release.yml`).
2075
2108
 
2109
+ ### Hub state dir: upgrades and service units
2110
+
2111
+ A dir counts as the hub state dir only when it holds hub state (`state.json`, `token-keys.json`, `agent/`, `blobs/`,
2112
+ `concierge-sessions.json`, `bin/`, `hub.log`, `hub.pid`, `claude-code/` or `codex/`); `hubctl setup`'s `bootstrap/`
2113
+ alone does not count, and setup writes `bootstrap/` into the dir the hub resolves. A relative `XDG_STATE_HOME` is
2114
+ ignored (the XDG spec allows only absolute paths). Nothing moves on upgrade: with
2115
+ `XDG_STATE_HOME` set and an existing `~/.local/state/hub`, the hub keeps that one and logs the `mv` command to switch.
2116
+
2117
+ **Service units must see the same `XDG_STATE_HOME` as your shell** (or set the same `HUB_STATE_DIR`): the resolver
2118
+ runs in every process, so a unit without `XDG_STATE_HOME` next to a shell that has it can pick a different dir on a
2119
+ fresh install (two `hub.pid` locks, two Concierge lock files, launchers in one dir and logs in the other). The units
2120
+ hub writes itself (`hub claude-code install`, `hub codex-spoke install`) already pass `XDG_STATE_HOME` and
2121
+ `HUB_STATE_DIR` through. A unit you write yourself, such as one running `opencode serve --service` (the OpenCode
2122
+ background service, which loads the hub plugin), must set `Environment=XDG_STATE_HOME=…` (systemd) or the plist's
2123
+ `EnvironmentVariables` (launchd) to your shell's value, or `HUB_STATE_DIR`.
2124
+
2125
+ ### Testing and preview isolation
2126
+
2127
+ A test or preview OpenCode with the hub plugin shares the user's files unless told otherwise: its spoke may
2128
+ auto-start a hub, write the `hub`/`hubctl` launchers, the Concierge lock file and `hub.log`, and read Herdr. Keep it
2129
+ apart:
2130
+
2131
+ - **State**: an isolated instance MUST set `HUB_STATE_DIR` to a scratch directory and its own `HOME` (plus its own
2132
+ `XDG_CONFIG_HOME` / `XDG_STATE_HOME` / `XDG_DATA_HOME` / `XDG_CACHE_HOME`). `XDG_STATE_HOME` alone is not enough:
2133
+ the state dir is sticky, so with the real `~/.local/state/hub` present it is used even when `XDG_STATE_HOME` points
2134
+ elsewhere. With `HUB_STATE_DIR` set the plugin never links `~/.local/bin/hub` and migrates nothing. With `HUB_STATE_DIR` set,
2135
+ the Concierge lock is read and written only there: an isolated instance never reads the user's
2136
+ `~/.local/state/hub` or `$XDG_STATE_HOME/hub`.
2137
+ - **Spoke config** (`config.json` in its own `XDG_CONFIG_HOME/hub`, or `HUB_SPOKE_CONFIG`): `"herdr": false` (no
2138
+ reading the user's Herdr panes), `"autoStartLocalHub": false` (no hub started on a loopback URL that is down), and
2139
+ the test hub's URL.
2140
+ - **Sessions**: create them only in scratch repositories (a temp dir with `git init`), never in a real project, so
2141
+ a stray session, launch or worktree cannot land in your work.
2142
+ - Never restart the user's `opencode serve --service`; start a private `opencode serve` on its own port and
2143
+ password (as the `*.it.test.ts` suites do).
2144
+
2076
2145
  ## Known limits
2077
2146
 
2078
2147
  - Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-claude-code",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
@@ -1,18 +1,18 @@
1
1
  // Where the Claude Code spoke keeps its files: <hub state dir>/claude-code (the hub state dir is $HUB_STATE_DIR, else
2
- // ~/.local/state/hub), a private (0700) directory holding the daemon's socket (daemon.sock, 0600), its log and the
2
+ // $XDG_STATE_HOME/hub or ~/.local/state/hub), a private (0700) directory holding the daemon's socket (daemon.sock, 0600), its log and the
3
3
  // plugin directories the wrapper loads with --plugin-dir. $HUB_CLAUDE_SOCKET names another socket (tests, or a
4
4
  // short path when the state dir is too deep for a Unix socket path).
5
5
  import { chmodSync, lstatSync, mkdirSync } from "node:fs"
6
6
  import { homedir } from "node:os"
7
7
  import path from "node:path"
8
8
  import { hubEnv, type Env } from "../../../src/env.ts"
9
- import { APP_DIR, stateBase } from "../../../src/migrate.ts"
9
+ import { resolveHubStateDir } from "../../../src/migrate.ts"
10
10
 
11
11
  /** Longest Unix socket path (macOS sun_path is 104 bytes with the terminating NUL). */
12
12
  export const SOCKET_PATH_MAX = 103
13
13
 
14
14
  export function claudeStateDir(env: Env = process.env, home: string = homedir()): string {
15
- return path.join(hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR), "claude-code")
15
+ return path.join(resolveHubStateDir(env, home).dir, "claude-code")
16
16
  }
17
17
 
18
18
  export function socketPath(env: Env = process.env, home: string = homedir()): string {