@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.
- package/CHANGELOG.md +326 -1
- package/README.md +85 -16
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +723 -281
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +9 -4
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +90 -7
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/protocol/src/wire.ts +1 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +3 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +60 -17
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +11 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,331 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
|
|
580
|
-
you are notified once. Typing into a session (console, Voice, the harness) starts its chain over.
|
|
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 (
|
|
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 (
|
|
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`)
|
|
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
|
|
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).
|
|
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
|
|
1880
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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(
|
|
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 {
|