@huynle/hub 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +213 -1
  2. package/README.md +84 -15
  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 +649 -242
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  9. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  10. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  11. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  12. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  13. package/packages/cli/src/cli.ts +3 -3
  14. package/packages/cli/src/http.ts +1 -0
  15. package/packages/cli/src/launches.ts +9 -3
  16. package/packages/cli/src/login.ts +2 -2
  17. package/packages/cli/src/mcp.ts +7 -3
  18. package/packages/cli/src/messaging.ts +4 -2
  19. package/packages/cli/src/profiles-mcp.ts +301 -0
  20. package/packages/cli/src/setup/commands.ts +20 -2
  21. package/packages/cli/src/spoke-services.ts +1 -1
  22. package/packages/cli/src/teams-mcp.ts +59 -2
  23. package/packages/codex/package.json +1 -1
  24. package/packages/codex/src/paths.ts +3 -3
  25. package/packages/node/package.json +1 -1
  26. package/packages/node/src/config.ts +10 -2
  27. package/packages/node/src/opencode-warm.ts +27 -8
  28. package/packages/node/src/run.ts +3 -2
  29. package/packages/preset/package.json +1 -1
  30. package/packages/protocol/package.json +1 -1
  31. package/packages/protocol/src/index.ts +7 -1
  32. package/packages/protocol/src/launch.ts +14 -0
  33. package/packages/runtime-openshell/package.json +1 -1
  34. package/packages/runtime-sdk/package.json +1 -1
  35. package/packages/sdk/package.json +1 -1
  36. package/packages/sdk/src/errors.ts +1 -0
  37. package/packages/sdk/src/index.ts +1 -0
  38. package/packages/sdk/src/types.ts +22 -2
  39. package/packages/sdk/src/wake.ts +20 -0
  40. package/packages/spoke-sdk/package.json +1 -1
  41. package/src/central/agent/acting.ts +3 -0
  42. package/src/central/agent/audit.ts +1 -1
  43. package/src/central/agent/decisions.ts +58 -2
  44. package/src/central/agent/delivery.ts +101 -27
  45. package/src/central/agent/errors.ts +2 -0
  46. package/src/central/agent/escalation.ts +10 -2
  47. package/src/central/agent/events.ts +7 -1
  48. package/src/central/agent/launch-engine.ts +173 -30
  49. package/src/central/agent/launch-routes.ts +20 -9
  50. package/src/central/agent/launch-spec.ts +26 -2
  51. package/src/central/agent/launch-store.ts +55 -2
  52. package/src/central/agent/message-limits.ts +89 -11
  53. package/src/central/agent/message-routes.ts +8 -3
  54. package/src/central/agent/messages.ts +3 -2
  55. package/src/central/agent/placement.ts +93 -18
  56. package/src/central/agent/read-routes.ts +3 -0
  57. package/src/central/agent/routes.ts +4 -2
  58. package/src/central/agent/webhooks.ts +17 -2
  59. package/src/central/artifacts/jobs-routes.ts +4 -1
  60. package/src/central/concierge/board-routes.ts +13 -2
  61. package/src/central/concierge/inbound.ts +4 -7
  62. package/src/central/concierge/prompt.ts +4 -3
  63. package/src/central/concierge/proposals.ts +8 -2
  64. package/src/central/concierge/routes.ts +62 -2
  65. package/src/central/concierge/session-routes.ts +62 -6
  66. package/src/central/concierge/supervisor.ts +34 -7
  67. package/src/central/concierge/watchlist-routes.ts +4 -2
  68. package/src/central/config.ts +21 -5
  69. package/src/central/http.ts +38 -15
  70. package/src/central/main.ts +1 -1
  71. package/src/central/registry.ts +26 -1
  72. package/src/central/teams/intake.ts +20 -6
  73. package/src/central/teams/staffing.ts +22 -0
  74. package/src/central/workspaces/sidechats-routes.ts +1 -1
  75. package/src/index.ts +39 -18
  76. package/src/migrate.ts +109 -3
  77. package/src/spoke/env-inject.ts +8 -1
  78. package/src/spoke/hub-tool.ts +24 -10
  79. package/src/spoke/launchers.ts +6 -4
  80. package/src/spoke/listing.ts +18 -6
  81. package/src/spoke/localhub.ts +14 -4
  82. package/src/spoke/locations.ts +40 -0
  83. package/src/spoke/session-context.ts +107 -27
  84. package/src/spoke/spoke.ts +13 -5
  85. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  86. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  87. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  88. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  89. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  90. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  91. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  92. package/web/dist/assets/index-BabrNDB3.css +1 -0
  93. package/web/dist/assets/index-lIOfSYck.js +34 -0
  94. package/web/dist/assets/live-BDq0AABg.js +5 -0
  95. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  96. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
  97. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
  98. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
  99. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  100. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  101. package/web/dist/index.html +4 -4
  102. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  103. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  104. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  105. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  106. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  107. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  108. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  109. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  110. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  111. package/web/dist/assets/live-YyTjE47g.js +0 -5
  112. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,218 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.8.1
4
+
5
+ A fix-up release after 0.8.0. App and gateway messages keep waking idle sessions (per-conversation hops with a relay
6
+ guard, and the reason when a wake is skipped); launches say why they wait; the Concierge gets per-machine Directory
7
+ and Model dropdowns, full-detail Decision cards (tap-tier ones too, never by voice), plainer Activity and fail-closed
8
+ hardening; the console uses in-page dialogs, collapsible sidebar sections and names instead of raw IDs; the right
9
+ rail's Branch and Changes work for background-service sessions; the hub state dir honours `XDG_STATE_HOME` without
10
+ moving existing state; MCP gains profile lifecycle tools. No migration is needed. Codex and Claude Code fork/revert
11
+ remain for a later release.
12
+
13
+ ### MCP: profile lifecycle and team staffing
14
+ - **Profile lifecycle over MCP** (`hub mcp`, `POST /mcp`): `profile_show`, `profile_versions`, `profile_create`,
15
+ `profile_update`, `profile_version_create` (content or `fromVersion` rollback), `profile_skill_set` /
16
+ `profile_skill_remove`, `profile_command_set` / `profile_command_remove` (attach or detach one skill or command as
17
+ the next immutable version), `profile_archive` / `profile_restore` and `profile_assign` / `profile_unassign`. Each
18
+ is an existing `/api/agent/profiles…` route with its scopes (`profiles:read`, `profiles:manage`,
19
+ `profiles:assign` + `sessions:read`); no server change ([docs/profiles.md](docs/profiles.md#sdk-and-mcp)).
20
+ - **`team_staff` / `team_replace` describe the nested launch spec** (`workspace{repo, mode, branch, base, remote}`,
21
+ `placement{machine, labels, mode}`, `runtime{kind, profile}`, `end{when, timeout, cleanup}` and the top-level
22
+ fields) in their input schema ([docs/teams.md](docs/teams.md#mcp-tools)).
23
+ - **Launch specs name misplaced fields**: an unknown launch field answers every unknown field at once and where each
24
+ belongs (`Unknown launch fields repo, cleanup: did you mean workspace.repo, end.cleanup?`), with the nested shape
25
+ as the hint, for `POST /api/agent/launches`, team staffing and the MCP tools.
26
+
27
+ ### Messaging and Concierge settings
28
+ - **Settings → Concierge: Directory and Model are dropdowns of the picked machine.** Directory lists the projects its
29
+ OpenCode service reports under (or at) its node's workspace roots, then the other roots, with **Other folder…** for
30
+ a typed path (a project that is the root itself is listed once, as a project, and the hint names the roots: FX-11);
31
+ picking another machine clears a directory that machine does not report. Model lists the machine's OpenCode models
32
+ with **Machine default** and **Other model…**; picking another machine resets a model its list lacks. Loading,
33
+ offline and error states keep the typed fallback; hints are linked to the dropdowns (`aria-describedby`). New
34
+ user-only route `GET /api/agent/concierge/machines/:id/models` (404 for a machine that is not yours, 503
35
+ `offline` without a connected service, a reported session, or a spoke answer).
36
+ - **App asks stop waking sessions after 4 round trips (S1, wge0vjdz).** An app token was one hop-depth party for all
37
+ its conversations and had no human turn to reset it, so every ask/reply round trip raised it by 2 and from the 5th
38
+ ask on (hop 9 > the cap of 8) nothing it sent woke anyone for 24 h; the spoke answered a bare `queued`. Apps now
39
+ count hops per conversation: a new `msg`/`ask` from an app is hop 1, replies and declines to an app raise nothing,
40
+ and an app's own reply or decline is the hop of the message it answers + 1 (reply ping-pong still hits the cap).
41
+ Session-to-session chains, human-turn resets, the pair limit (20/10 min) and the wake budget (30/10 min, which
42
+ bound an app answering one session with new messages forever) are unchanged. Covers app tokens, `app:concierge`,
43
+ the workspace coordinator and team intake.
44
+ - **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
45
+ msg/ask to *another* session carries that hop + 1, so a relay loop A → app → B → app → A reaches the hop cap after
46
+ about 8 hops instead of only the rate limits; spaced-out asks and one-to-one app conversations still start at hop
47
+ 1. The hub's own parties (Concierge, workspace coordinator, team intake) keep no relay depth: they send on events
48
+ and submissions, not on what they receive.
49
+ - **Team intake continues the submitter's chain.** A session's submission is delivered from `app:team-intake` at
50
+ the submitter's depth + 1 (kept while it is queued), not hop 1.
51
+ - **Hop-cap alert wording.** "Agent messages went 8 hops without you (latest: A → B) …" instead of "Sessions
52
+ messaged each other", which was wrong when an app was in the chain.
53
+ - **Never give agents an app token** (README): it lets them send at a fresh hop depth.
54
+ - **Why a message did not wake.** `POST /api/agent/msg|ask|reply|decline` answers carry `woke` and, when false,
55
+ `wakeSkipped: wake_false | subagent | hop_cap | budget` (additive; SDK `Sent.woke`, `Sent.wakeSkipped`, type
56
+ `WakeSkip`, and `wakeSkipNote`/`WAKE_SKIP_TEXT` for the shared wording). `hub msg`/`ask`/`reply`/`decline`, the
57
+ MCP tools and the OpenCode `hub` tool (text and metadata) print it: `queued (read at its next turn) — wake
58
+ skipped: hop limit reached (8 agent messages without a human turn)`; `wake_false` reads "sent without a wake".
59
+
60
+ ### Launches say why they wait
61
+ - A queued launch stores why it waits: `GET /api/agent/launches/:id` carries `waiting: {wait, reason, since}` and
62
+ `reason` is the same text. Written only when it changes, announced as a `launch` event and logged once per change
63
+ (`[hub] launch <id> waits (<wait>): <reason>`); cleared once placed. Before, it said only "Queued".
64
+ - Specific reasons: the active cap names who holds it (`4 of 4 active launches: 3 by app "ci-bot", 1 by you`), a
65
+ full node gives its counts (`baruch (4/4 used, 0 reserved)`), a node that does not fit its first problem.
66
+ - `hub launches list|show` and the MCP launch tools print the wait; Settings → Agents & access has a new **Launches**
67
+ list (live launches, updated from the event stream, why each waits, Stop with a per-row accessible name; the
68
+ Concierge's own launch is managed in Settings → Concierge); the Concierge's status says "Waiting: …" instead of
69
+ "Starting".
70
+ - An app or session sees its own launch's wait in a launcher-safe form: counts only for the cap ("1 yours, 3 by
71
+ others"), no machine names. The user sees the full text.
72
+ - Only a new wait kind is a launch change (journal `launch.changed` with `data.wait`, SSE); never a webhook, so
73
+ webhooks keep one delivery per state (`launch.queued` once). Text-only updates (slot counts) go to the console's
74
+ live stream only; artifact jobs show them without a new job revision.
75
+ - The Concierge's own launch no longer counts toward `agents.launches.maxActive` (it still takes a node slot).
76
+ - Revoking an app token does not stop its launches; they are flagged (`launcherRevoked`) in the user's lists and in
77
+ the cap's waiting reason so the user can stop them. Launches record the app token's label (`byLabel`).
78
+ - A node cleanup that failed while the node stayed connected is retried (capacity reports, the 15 s sweep, up to 5
79
+ times; and on reconnect), so the node no longer keeps a slot for an ended launch.
80
+ - A running launch ends `lost` when the same node instance that reported it leaves it out of its reports for 5
81
+ minutes while no spoke reports its session, freeing its reserved slot. Never on a disconnected node, a short gap,
82
+ or a restarted node daemon (a new instance) that never reported it; a lost launch whose session is still reported
83
+ keeps its workspace. `agents.launches.lostAfterMinutes`: 0–1440, or 0/false to turn it off.
84
+ - A launch stopped between placement's placing and provisioning steps (e.g. the Concierge reaping an orphan) is
85
+ skipped quietly instead of logging a placement error.
86
+ - Engine timers are clamped to setTimeout's 32-bit limit; an `end.timeout` over 24.8 days (up to 30) no longer fires
87
+ at once.
88
+
89
+ ### Concierge hardening
90
+ - **Activity in plain words.** Settings → Agents → Activity says what the Concierge did without internal terms:
91
+ "Approved by the Concierge inside your grant. Its note: «…»", "Needed your tap: shell commands, outside folders
92
+ and tools always do", "The Concierge proposed this for your answer", "Refused: only you can message the
93
+ Concierge". Its note is framed as data (its own « » become ‹ ›, bidi and invisible controls dropped, one line, at
94
+ most 300 characters), so it cannot fake text after it.
95
+ No `inGrant`, `auto` or raw Decision IDs; the Concierge is told to write its why in plain words too
96
+ (Concierge prompt v7).
97
+ - **Decision lookups in the Concierge panel** skip IDs in a reply that is still streaming (half an ID was looked up
98
+ and remembered as unknown), back off on 429/5xx/network failures (2 s doubling, ≤ 1 min) instead of retrying on
99
+ every render, and run at most 4 at once.
100
+ - **Watchlist after a reconnect.** A process counts as reporting its sessions only after its first sessions frame
101
+ (catalog `sessionsPending`, additive), so the watchlist and board no longer empty for a moment after `hello`.
102
+ - **`concierge_find`** with no letters or digits ("@", "?!") no longer lists every session: the hub's
103
+ `GET /api/agent/sessions?q=` matches nothing for such a `q` (a blank `q` still filters nothing).
104
+ - **Concierge session lock file** (`concierge-sessions.json`): writes from several OpenCode processes are serialized
105
+ under an exclusive `<file>.lock` (a stale one is broken), so no process drops another's locks.
106
+ - **Identity:** a Concierge session older than the 500 the hub keeps still counts as one while its machine reports
107
+ it locked (spokes mark locked sessions `locked: true` from their first sessions frame after a restart). Concierge
108
+ identity reads (route gate, manage guard, acting guard, inbound/outbound rules, team staffing, Decision and audit
109
+ 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
110
+ read refuses with a neutral 503 `busy` "Hub state is busy, try again" (new error code, CLI exit 7), audited as
111
+ decision `busy`, outcome `refused`; team staffing
112
+ (attach, replace, a staffing launch) refuses a Concierge session in the service as well as the route.
113
+ - **Arm badge:** the console lapses an arm at its expiry itself, so a missed `armed: null` never leaves a stale ✨.
114
+
115
+ ### Console polish
116
+ - **Collapsible sidebar sections**: Needs you, Running, Pinned, the session groups, Recents and Workspaces collapse
117
+ from their heading (mouse or keyboard, `aria-expanded`), remembered on this device. A collapsed section shows its
118
+ count; Needs you always shows its count, so collapsing it never hides that something waits.
119
+ - **In-page confirm dialog** instead of the browser's `window.confirm` (Revert, stopping a launch, turning the Concierge off, revoking app tokens, grants,
120
+ machines and devices, turning agent actions off, signing out, admin user actions, resetting speech settings):
121
+ focus starts on the primary action (Enter confirms), on Cancel for destructive (red) actions; Tab stays inside; a
122
+ held Enter (key auto-repeat) is ignored; Escape or Cancel cancels (Escape closes only the dialog, not the
123
+ Concierge panel or a popover behind it); 44 px buttons on touch screens.
124
+ - **/new**: a machine that reports exactly one project has it chosen; Enter without a project says so and moves the
125
+ focus to the Project chip, which stands out while nothing is chosen.
126
+ - **Concierge panel** is fitted with the sidebar and rail: at 1280 px the transcript keeps 420 px (it had 345); at
127
+ 1024 px it gets 304 px with every panel at its minimum (it had 119), 420 px without a rail.
128
+ - Fixes: a path with a leading dot in the Changes list (`.claude/settings.local.json`) no longer shows the dot at the
129
+ end; "Denied by you" shows once when a denial stops the turn; a grant's expiry rounds to the nearest whole day
130
+ everywhere (no more "7 d" on one page and "6 d" on another); Decision links in the Concierge panel no longer push
131
+ the text after them to a new line; the panel's disabled Send button looks disabled.
132
+ - **The right rail's Branch and Changes come from the session's own directory** (FX-10). OpenCode's plugin `vcs`
133
+ API answers for the plugin instance's own location whatever location is passed, and the spoke used the first
134
+ instance's (the background service's is its `$HOME`), so a service session in a project showed "No changes." and
135
+ Branch "—" (and the per-file diff read the wrong tree). The spoke now reads through the plugin instance whose
136
+ location is the session's directory, else the own server's API (which honours the location), else shows nothing;
137
+ a read that takes over 5 s (a hung server) is left out, so the rest of the summary still answers.
138
+
139
+ ### Robustness and the hub state directory
140
+ - **State dir honours `XDG_STATE_HOME`, sticky**: the plugin (launchers, Concierge lock), the local hub, the hub
141
+ server and the Claude Code and Codex spokes resolve it the same way: `$HUB_STATE_DIR`; else `$XDG_STATE_HOME/hub`
142
+ if it exists; else an existing `~/.local/state/hub`; else `$XDG_STATE_HOME/hub` when XDG_STATE_HOME is set (fresh
143
+ installs); else `~/.local/state/hub`. The legacy `opencode-hub` / `opencode-voice` state migrates from under
144
+ `$XDG_STATE_HOME` and `~/.local/state`. `hub claude-code install` / `hub codex-spoke install` carry
145
+ `XDG_STATE_HOME` into the service. The Concierge lock is read from both default places (written to the resolved
146
+ one), so a session locked before the upgrade stays locked; with `HUB_STATE_DIR` set (an isolated preview or test)
147
+ it is read and written only there.
148
+ **Upgrade note:** nothing moves. If `XDG_STATE_HOME` is set and you have `~/.local/state/hub`, the hub keeps using
149
+ it and logs one line at startup with the command to switch: stop the hub and OpenCode, then
150
+ `mv ~/.local/state/hub "$XDG_STATE_HOME/hub"`. Isolated test/preview instances must set `HUB_STATE_DIR` and their
151
+ own `HOME`; `XDG_STATE_HOME` alone does not isolate them from an existing `~/.local/state/hub`.
152
+ A dir counts only when it holds hub state (`state.json`, `token-keys.json`, `agent/`, `blobs/`,
153
+ `concierge-sessions.json`, `bin/`, `hub.log`, `hub.pid`, `claude-code/`, `codex/`), never `bootstrap/` alone, and
154
+ `hubctl setup` now writes `bootstrap/` into the dir the hub resolves (its state root follows the resolver; other
155
+ roots unchanged). The legacy `opencode-hub` / `opencode-voice` copy also fills a hub dir that holds no hub state
156
+ yet (e.g. only `bootstrap/` from an earlier setup), next to `bootstrap/` and never over it. A relative
157
+ `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
158
+ shell and a unit can use different dirs: hub's own `claude-code` / `codex-spoke` units already pass it; a unit you
159
+ wrote for `opencode serve --service` needs it set.
160
+ - **Warmer hardening** (`hub node`): OpenCode's `service.json` is used only when it is a regular file owned by this
161
+ user and not group/world-writable (no symlinks), and its pid is this user's live process (EPERM is not ours). Warm
162
+ requests wait 30 s by default (was 10 s), configurable with node.json `opencode.timeoutSeconds` (1–300).
163
+ - `hubctl setup --verify-launch` warns about a stored plain-http (non-loopback) login, like every `hub` command.
164
+ - Refused hub URLs (`hub login`, `hubctl machine enroll`) no longer echo user info: shown as `https://***@host`
165
+ (also `http:/u:p@h`, `http:u:p@h`, `http:\\u:p@h`, leading whitespace and scheme-less `user:pass@host`).
166
+ - **Listings are cancelled**: a session listing that times out aborts its signal (`SpokeHost.listRecent` gets
167
+ `signal`, passed to every OpenCode request), so a hung server does not collect abandoned listings.
168
+ - **Pending messages tell their sender when they expire**: a message still pending after 24 h (recipient offline or
169
+ busy) gets delivery `"expired"` (new `MessageDelivery` value), its sending session gets a quiet hub notice
170
+ (`wake: false`, never the text), the user an alert (push kind `message`), and the change is journaled
171
+ (`message.changed`).
172
+ - `HEAD /healthz` and `HEAD /readyz` answer like GET (same status and headers, no body) instead of 404, for uptime
173
+ monitors and `curl -I`. No other route changed.
174
+ - Docs: README "Testing and preview isolation" (`HUB_STATE_DIR`, own `HOME`/`XDG_*`, `herdr: false`,
175
+ `autoStartLocalHub: false`, sessions only in scratch repos).
176
+
177
+ ### Decision cards and the Concierge panel
178
+ - **Decision cards say what they ask for** (FX-05): the Concierge panel's armed and listed cards, and every Decision
179
+ card on Home and in sessions, show the requested action as a session's own permission card does: the permission
180
+ kind, the exact command, paths or patterns (the session's live request when the console has it), who asked, why it
181
+ is outside the grant and the stated purpose. A Concierge proposal shows its verb ("Send a prompt as you", "Stop the
182
+ session", …), the target session, the exact prompt or form answer, and the Concierge's why; its question heads the
183
+ card without repeating the body. A plain ask about a session that waits on one permission shows that request.
184
+ Long commands and prompts wrap and are cut with **Show all** (a session's permission card too). All of it is
185
+ rendered as plain text, from the user's own console data: the Concierge's board and update lines still carry no
186
+ blocker summaries or Decision text.
187
+ - **The card says where its detail comes from** (FX-05): "Live request" (the session's pending request), "The hub's
188
+ record of the request" (new structured fields on Decisions: `action.request {action, resources, save?}` and
189
+ `action.reason` on escalations, `action.request` on deny proposals; bounded to 20 × 2,000 characters), or "From
190
+ the request text" for older Decisions, now parsed without splitting a command at ", " or at a ". Outside its
191
+ grant: " inside it. Bidi and invisible characters in commands, paths and prompts show as markers ("⟨U+202E⟩"),
192
+ also on a session's permission card (tag characters U+E0000–E007F, separators, fillers too; a valid subdivision
193
+ flag and ZWJ emoji stay intact); text is cut by grapheme; **Show all** buttons name their field and control the
194
+ text they open. What the hub did not store is recorded with the request (`resourcesTotal`, `savesTotal`, `cut`
195
+ [{field, index, length}], `truncated`) and shown on the card ("… cut at 2,000 characters (of 3,017)", "+10 more
196
+ items not stored", "The hub received this request already shortened"), so a shortened command never reads as
197
+ complete. The structured request and reason are for the user's console only: the Concierge's Decision reads
198
+ (`/api/agent/concierge/decisions`, `concierge_decision`) return the records exactly as before.
199
+ - **Tap-tier Decisions in the Concierge panel** (FX-05b): the panel lists the Concierge's tap-tier Decisions (shell,
200
+ outside folders, tools, prompt/answer proposals) with the same detail, answered by a tap on the card. They are
201
+ never armed and never answered by voice or text; the button's badge and the panel's list now agree.
202
+ - **Fix: Decisions and the armed card no longer vanish at random** (quirk pgd96twg): the open list and the armed
203
+ Decision reloaded on every stream open replaced what the stream had said while the reload was out. A reload's
204
+ snapshot now merges the Decision events that came meanwhile (and an older reload never overrides a newer one),
205
+ and an arm event that came while `GET …/arm` was out wins over its answer. A load out for over 30 s is forgotten
206
+ (no events buffered forever), and an older `GET …/arm` never overrides a newer one.
207
+
208
+ - **Sessions by name, never a raw ID** (FX-12): Decision cards (their heading, "from", Requested by and Session), a
209
+ session's permission card (the recommender, who answered), hub message rows, Settings → Agents & access Activity
210
+ and Settings → Concierge's watch and mute lists name a session by its @name (the hub directory), else its title,
211
+ else its project folder and a short ID ("hub · ses_e6256f…"); the full ID is in the tooltip. An escalation's
212
+ heading is built from the hub's structured request ("API refactor asks to approve shell"; the command and reason
213
+ are in the details), other hub-written questions have their `ses_…` IDs named. Activity says "a permission
214
+ request", "a form" and "grant", with the IDs on hover. Display only: the data, the hub and what the Concierge
215
+ model sees are unchanged.
4
216
 
5
217
  ## 0.8.0
6
218
 
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,6 +676,7 @@ 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
@@ -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.1",
4
4
  "description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
5
5
  "license": "MIT",
6
6
  "author": "Huy Le",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-claude-code",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "private": true,
5
5
  "description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -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 {