@huynle/hub 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +87 -52
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  8. package/packages/cli/src/artifacts-mcp.ts +8 -7
  9. package/packages/cli/src/mcp.ts +4 -3
  10. package/packages/cli/src/profiles-mcp.ts +3 -3
  11. package/packages/cli/src/team-intake-mcp.ts +8 -7
  12. package/packages/cli/src/teams-mcp.ts +2 -1
  13. package/packages/cli/src/tool-args.ts +6 -0
  14. package/packages/cli/src/watchlist.ts +5 -4
  15. package/packages/codex/package.json +1 -1
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/src/concierge.ts +4 -1
  20. package/packages/protocol/src/embedded-media.ts +59 -0
  21. package/packages/protocol/src/index.ts +83 -6
  22. package/packages/protocol/src/wire.ts +1 -0
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/client.ts +3 -1
  27. package/packages/spoke-sdk/package.json +1 -1
  28. package/packages/spoke-sdk/src/connection.ts +13 -1
  29. package/src/central/agent/acting-routes.ts +3 -2
  30. package/src/central/agent/acting.ts +67 -12
  31. package/src/central/agent/delivery.ts +12 -3
  32. package/src/central/agent/escalation.ts +30 -9
  33. package/src/central/agent/launch-spec.ts +38 -19
  34. package/src/central/agent/routes.ts +7 -0
  35. package/src/central/concierge/context.ts +3 -1
  36. package/src/central/concierge/prompt.ts +7 -4
  37. package/src/central/http.ts +50 -21
  38. package/src/central/registry.ts +23 -3
  39. package/src/central/revert-windows.ts +66 -0
  40. package/src/central/spoke-ws.ts +6 -3
  41. package/src/central/teams/staffing.ts +2 -2
  42. package/src/index.ts +26 -14
  43. package/src/spoke/deliver.ts +6 -0
  44. package/src/spoke/fork-revert.ts +94 -9
  45. package/src/spoke/hub-tool.ts +2 -2
  46. package/src/spoke/locations.ts +80 -0
  47. package/src/spoke/profile-apply.ts +24 -14
  48. package/src/spoke/session-context.ts +76 -6
  49. package/src/spoke/spoke.ts +116 -36
  50. package/src/spoke/timeline.ts +30 -3
  51. package/src/spoke/tracker.ts +17 -1
  52. package/src/status.ts +2 -1
  53. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-R0ZTpHca.js} +1 -1
  54. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-C9KrBna8.js} +1 -1
  55. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-sP2mZccC.js} +1 -1
  56. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-BXbJvTUS.js} +2 -2
  57. package/web/dist/assets/{ProfilesDestination-DhVVcAep.js → ProfilesDestination-D7XyLTJ7.js} +1 -1
  58. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-Dgxt8UI3.js} +1 -1
  59. package/web/dist/assets/{Watchtower-DxaJYQp_.js → Watchtower-DJHx6cWw.js} +2 -2
  60. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  61. package/web/dist/assets/{live-BDq0AABg.js → live-OgQWPrSY.js} +1 -1
  62. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-DjEipyto.js} +1 -1
  63. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-B648jKGO.js} +1 -1
  64. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-CNLyboIj.js} +1 -1
  65. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/assets/index-lIOfSYck.js +0 -34
package/CHANGELOG.md CHANGED
@@ -1,5 +1,118 @@
1
1
  # Changelog
2
2
 
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
+
3
116
  ## 0.8.1
4
117
 
5
118
  A fix-up release after 0.8.0. App and gateway messages keep waking idle sessions (per-conversation hops with a relay
package/README.md CHANGED
@@ -681,7 +681,7 @@ allow patterns (else `needs_user`) → the hourly cap → the kill switches agai
681
681
  **Escalation.** An approval outside the grant answers `needs_user` with the evaluation (`verdict`, `reason`, the
682
682
  grants checked, and a `tier`) and opens a Decision for you: one per agent and request (asking again updates its
683
683
  reason), with Approve once (or Approve always, when asked), Deny (safe), and the agent's `--why` as its
684
- 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
685
685
  notification) for shell, `external_directory`, MCP and unknown tools and for anything near a never rule; `confirm`
686
686
  otherwise. Your answer replies to the permission **as you** (no grant or kill switch applies to you); if the request
687
687
  was answered elsewhere or ended first, the Decision closes and the agent's next try is `not_found`. A request's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.8.1",
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.1",
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",
@@ -7,7 +7,7 @@ import { createInterface } from "readline";
7
7
  // packages/cli/package.json
8
8
  var package_default = {
9
9
  name: "@huynle/hub-cli",
10
- version: "0.8.1",
10
+ version: "0.8.2",
11
11
  private: true,
12
12
  description: "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
13
13
  license: "MIT",
@@ -863,7 +863,7 @@ class HubClient {
863
863
  return this.call("POST", `/api/decisions/${segment(id)}/answer`, { body, ...signalOf(answer) });
864
864
  }
865
865
  approve(addr, requestID, options = {}) {
866
- const body = { requestID, ...options.always ? { always: true } : {}, ...whyField(options) };
866
+ const body = { requestID, ...options.always ? { always: true } : {}, ...options.recommend ? { recommend: true } : {}, ...whyField(options) };
867
867
  return this.call("POST", `${AGENT}/sessions/${segment(addr)}/approve`, { body, ...signalOf(options) });
868
868
  }
869
869
  deny(addr, requestID, options = {}) {
@@ -3852,6 +3852,9 @@ function formAnswerProblem(v) {
3852
3852
  }
3853
3853
  var REVERT_FILES_MAX = 50;
3854
3854
  var REVERT_FILE_MAX_CHARS = 1000;
3855
+ function isRevertCommitted(v) {
3856
+ return isObj(v) && nonEmpty(v.messageID) && v.messageID.length <= 300 && (v.from === undefined || num(v.from)) && num(v.until) && num(v.at);
3857
+ }
3855
3858
  var FORWARDED_EVENTS = new Set([
3856
3859
  "session.created",
3857
3860
  "session.renamed",
@@ -3943,13 +3946,14 @@ function isLaunchCreate(req) {
3943
3946
  }
3944
3947
  var AGENT_NAME_MAX = 100;
3945
3948
  var PERMISSION_MESSAGE_MAX = 1000;
3949
+ var RES_CODE_MAX = 40;
3946
3950
  var isModelSelection = (v) => isObj(v) && typeof v.providerID === "string" && ID.test(v.providerID) && typeof v.modelID === "string" && ID.test(v.modelID) && (v.variant === undefined || typeof v.variant === "string" && ID.test(v.variant));
3947
3951
  var SESSION_TOKEN_TEXT = new RegExp(`^[\\x21-\\x7e]{1,${SESSION_TOKEN_MAX_CHARS}}$`);
3948
3952
  function isSessionToken(v) {
3949
3953
  return isObj(v) && nonEmpty(v.sessionID) && v.sessionID.length <= 300 && str2(v.token) && SESSION_TOKEN_TEXT.test(v.token) && num(v.exp);
3950
3954
  }
3951
3955
  function isSession(v) {
3952
- return isObj(v) && nonEmpty(v.id) && str2(v.title) && str2(v.directory) && num(v.updated) && typeof v.owned === "boolean" && (v.parentID === undefined || str2(v.parentID)) && (v.exposed === undefined || typeof v.exposed === "boolean") && (v.project === undefined || str2(v.project)) && (v.projectName === undefined || str2(v.projectName)) && (v.archived === undefined || typeof v.archived === "boolean") && (v.open === undefined || typeof v.open === "boolean") && (v.terminal === undefined || isTerminal(v.terminal)) && (v.revert === undefined || isSessionRevert(v.revert)) && (v.agent === undefined || nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX) && (v.contextRevision === undefined || Number.isSafeInteger(v.contextRevision) && v.contextRevision >= 0) && (v.locked === undefined || v.locked === true);
3956
+ return isObj(v) && nonEmpty(v.id) && str2(v.title) && str2(v.directory) && num(v.updated) && typeof v.owned === "boolean" && (v.parentID === undefined || str2(v.parentID)) && (v.exposed === undefined || typeof v.exposed === "boolean") && (v.project === undefined || str2(v.project)) && (v.projectName === undefined || str2(v.projectName)) && (v.archived === undefined || typeof v.archived === "boolean") && (v.open === undefined || typeof v.open === "boolean") && (v.terminal === undefined || isTerminal(v.terminal)) && (v.revert === undefined || isSessionRevert(v.revert)) && (v.revertCommitted === undefined || isRevertCommitted(v.revertCommitted)) && (v.agent === undefined || nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX) && (v.contextRevision === undefined || Number.isSafeInteger(v.contextRevision) && v.contextRevision >= 0) && (v.locked === undefined || v.locked === true);
3953
3957
  }
3954
3958
  var REVERT_STATUS = new Set(["added", "deleted", "modified"]);
3955
3959
  var count = (v) => Number.isSafeInteger(v) && v >= 0;
@@ -3957,7 +3961,7 @@ function isRevertFile(v) {
3957
3961
  return isObj(v) && nonEmpty(v.file) && v.file.length <= REVERT_FILE_MAX_CHARS && (v.status === undefined || REVERT_STATUS.has(v.status)) && (v.additions === undefined || count(v.additions)) && (v.deletions === undefined || count(v.deletions));
3958
3962
  }
3959
3963
  function isSessionRevert(v) {
3960
- return isObj(v) && nonEmpty(v.messageID) && v.messageID.length <= 300 && count(v.hidden) && (v.at === undefined || num(v.at)) && Array.isArray(v.files) && v.files.length <= REVERT_FILES_MAX && v.files.every(isRevertFile) && count(v.filesTotal) && v.filesTotal >= v.files.length;
3964
+ return isObj(v) && nonEmpty(v.messageID) && v.messageID.length <= 300 && count(v.hidden) && (v.at === undefined || num(v.at)) && Array.isArray(v.files) && v.files.length <= REVERT_FILES_MAX && v.files.every(isRevertFile) && count(v.filesTotal) && v.filesTotal >= v.files.length && (v.stagedAt === undefined || num(v.stagedAt));
3961
3965
  }
3962
3966
  var TERMINAL_STATUS = new Set(["idle", "working", "blocked", "done", "unknown"]);
3963
3967
  function isTerminal(v) {
@@ -4141,8 +4145,12 @@ function parseSpokeMessage(raw) {
4141
4145
  switch (m.t) {
4142
4146
  case "hello":
4143
4147
  return (m.role === undefined || m.role === "spoke") && num(m.v) && nonEmpty(m.processID) && (m.kind === "service" || m.kind === "standalone") && str2(m.directory) && num(m.pid) && (m.mode === "always" || m.mode === "session") && (str2(m.opencodeVersion) || m.opencodeVersion === undefined && m.harness !== undefined && m.harness !== HARNESS_OPENCODE) && str2(m.pluginVersion) && (m.features === undefined || featureList(m.features)) && (m.harness === undefined || token(m.harness, 64)) && (m.harnessVersion === undefined || str2(m.harnessVersion) && m.harnessVersion.length <= 100) && (m.evidence === undefined || token(m.evidence, 32)) && (m.launchID === undefined || str2(m.launchID) && ID.test(m.launchID)) ? m : undefined;
4144
- case "sessions":
4145
- return Array.isArray(m.sessions) && m.sessions.every(isSession) ? m : undefined;
4148
+ case "sessions": {
4149
+ if (!Array.isArray(m.sessions))
4150
+ return;
4151
+ const sessions = m.sessions.map(lenientSession);
4152
+ return sessions.every(isSession) ? { ...m, sessions } : undefined;
4153
+ }
4146
4154
  case "terminals":
4147
4155
  return m.herdr === undefined || isHerdrInfo(m.herdr) ? m : undefined;
4148
4156
  case "event":
@@ -4150,13 +4158,34 @@ function parseSpokeMessage(raw) {
4150
4158
  case "req":
4151
4159
  return nonEmpty(m.id) && isSpokeRequest(m.req) ? m : undefined;
4152
4160
  case "res":
4153
- return isResFrame(m) ? m : undefined;
4161
+ return isResFrame(m) ? lenientRes(m) : undefined;
4154
4162
  case "bye":
4155
4163
  return isByeFrame(m) ? m : undefined;
4156
4164
  default:
4157
4165
  return;
4158
4166
  }
4159
4167
  }
4168
+ function lenientRes(m) {
4169
+ if (m.code === undefined || nonEmpty(m.code) && m.code.length <= RES_CODE_MAX)
4170
+ return m;
4171
+ const { code: _dropped, ...rest } = m;
4172
+ return rest;
4173
+ }
4174
+ function lenientSession(v) {
4175
+ if (!isObj(v))
4176
+ return v;
4177
+ let out = v;
4178
+ if (out.revertCommitted !== undefined && !isRevertCommitted(out.revertCommitted)) {
4179
+ const { revertCommitted: _dropped, ...rest } = out;
4180
+ out = rest;
4181
+ }
4182
+ const revert = out.revert;
4183
+ if (isObj(revert) && revert.stagedAt !== undefined && !num(revert.stagedAt)) {
4184
+ const { stagedAt: _dropped, ...rest } = revert;
4185
+ out = { ...out, revert: rest };
4186
+ }
4187
+ return out;
4188
+ }
4160
4189
  function parseServerMessage(raw) {
4161
4190
  const m = parseFrame(raw);
4162
4191
  if (!m)
@@ -4167,7 +4196,7 @@ function parseServerMessage(raw) {
4167
4196
  case "req":
4168
4197
  return nonEmpty(m.id) && isServerRequest(m.req) ? m : undefined;
4169
4198
  case "res":
4170
- return isResFrame(m) ? m : undefined;
4199
+ return isResFrame(m) ? lenientRes(m) : undefined;
4171
4200
  case "bye":
4172
4201
  return isByeFrame(m) ? m : undefined;
4173
4202
  default:
@@ -5847,6 +5876,10 @@ function launchText(l) {
5847
5876
  `);
5848
5877
  }
5849
5878
 
5879
+ // packages/cli/src/tool-args.ts
5880
+ class ToolArgError extends Error {
5881
+ }
5882
+
5850
5883
  // packages/cli/src/artifacts-mcp.ts
5851
5884
  var MCP_INLINE_TEXT_BYTES = 256 * 1024;
5852
5885
  var KINDS = ["markdown", "code", "image", "table", "chart", "dashboard", "html", "app", "file"];
@@ -5870,7 +5903,7 @@ var FILE_ARGS = {
5870
5903
  var need = (args, key) => {
5871
5904
  const value = args[key];
5872
5905
  if (typeof value !== "string" || !value.trim())
5873
- throw new Error(`"${key}" is required (a non-empty string)`);
5906
+ throw new ToolArgError(`"${key}" is required (a non-empty string)`);
5874
5907
  return value;
5875
5908
  };
5876
5909
  var optional2 = (args, key) => {
@@ -5878,20 +5911,20 @@ var optional2 = (args, key) => {
5878
5911
  if (value === undefined || value === null)
5879
5912
  return;
5880
5913
  if (typeof value !== "string")
5881
- throw new Error(`"${key}" must be a string`);
5914
+ throw new ToolArgError(`"${key}" must be a string`);
5882
5915
  return value.trim() ? value : undefined;
5883
5916
  };
5884
5917
  function files(args, kind) {
5885
5918
  const text = args.text;
5886
5919
  if (text !== undefined && typeof text !== "string")
5887
- throw new Error('"text" must be a string');
5920
+ throw new ToolArgError('"text" must be a string');
5888
5921
  const blobs = args.blobs;
5889
5922
  if (blobs !== undefined && (!Array.isArray(blobs) || !blobs.every((b) => typeof b === "object" && b !== null)))
5890
- throw new Error('"blobs" must be a list of blob references');
5923
+ throw new ToolArgError('"blobs" must be a list of blob references');
5891
5924
  if (text === undefined && (blobs === undefined || blobs.length === 0))
5892
- throw new Error("give text or blobs (stage bigger content first and pass its reference)");
5925
+ throw new ToolArgError("give text or blobs (stage bigger content first and pass its reference)");
5893
5926
  if (text !== undefined && Buffer.byteLength(text, "utf8") > MCP_INLINE_TEXT_BYTES)
5894
- throw new Error(`text is larger than ${MCP_INLINE_TEXT_BYTES} bytes: stage it with uploadBlob and pass blobs`);
5927
+ throw new ToolArgError(`text is larger than ${MCP_INLINE_TEXT_BYTES} bytes: stage it with uploadBlob and pass blobs`);
5895
5928
  const path = optional2(args, "path");
5896
5929
  const entry = optional2(args, "entry");
5897
5930
  const mediaType = optional2(args, "mediaType") ?? (kind && DEFAULT_MEDIA[kind]) ?? "text/plain";
@@ -5958,7 +5991,7 @@ var artifactTools = [
5958
5991
  const title = need(args, "title");
5959
5992
  const kind = need(args, "kind");
5960
5993
  if (!KINDS.includes(kind))
5961
- throw new Error(`"kind" must be one of ${KINDS.join(", ")}`);
5994
+ throw new ToolArgError(`"kind" must be one of ${KINDS.join(", ")}`);
5962
5995
  const body = files(args, kind);
5963
5996
  const description = optional2(args, "description");
5964
5997
  const visibility = optional2(args, "visibility");
@@ -5997,7 +6030,7 @@ var KINDS2 = ["ask", "propose-edit", "run-action"];
5997
6030
  var need2 = (args, key) => {
5998
6031
  const value = args[key];
5999
6032
  if (typeof value !== "string" || !value.trim())
6000
- throw new Error(`"${key}" is required (a non-empty string)`);
6033
+ throw new ToolArgError(`"${key}" is required (a non-empty string)`);
6001
6034
  return value;
6002
6035
  };
6003
6036
  var optional3 = (args, key) => {
@@ -6005,24 +6038,24 @@ var optional3 = (args, key) => {
6005
6038
  if (value === undefined || value === null)
6006
6039
  return;
6007
6040
  if (typeof value !== "string")
6008
- throw new Error(`"${key}" must be a string`);
6041
+ throw new ToolArgError(`"${key}" must be a string`);
6009
6042
  return value.trim() ? value : undefined;
6010
6043
  };
6011
6044
  var integer = (args, key) => {
6012
6045
  const value = args[key];
6013
6046
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
6014
- throw new Error(`"${key}" is required (a whole number)`);
6047
+ throw new ToolArgError(`"${key}" is required (a whole number)`);
6015
6048
  return value;
6016
6049
  };
6017
6050
  function handlerOf(args) {
6018
6051
  const session = optional3(args, "session");
6019
6052
  const machine = optional3(args, "machine");
6020
6053
  if (session && machine)
6021
- throw new Error('give either "session" (an existing session) or "machine" + "repo" (a new handler session), not both');
6054
+ throw new ToolArgError('give either "session" (an existing session) or "machine" + "repo" (a new handler session), not both');
6022
6055
  if (session)
6023
6056
  return { session };
6024
6057
  if (!machine)
6025
- throw new Error('"machine" and "repo" are required to launch a handler session (or name an existing one with "session")');
6058
+ throw new ToolArgError('"machine" and "repo" are required to launch a handler session (or name an existing one with "session")');
6026
6059
  const repo = need2(args, "repo");
6027
6060
  const harness = optional3(args, "harness");
6028
6061
  const model = optional3(args, "model");
@@ -6086,7 +6119,7 @@ var artifactJobTools = [
6086
6119
  run: async (hub, args, signal) => {
6087
6120
  const kind = optional3(args, "kind");
6088
6121
  if (kind && !KINDS2.includes(kind))
6089
- throw new Error(`"kind" must be one of ${KINDS2.join(", ")}`);
6122
+ throw new ToolArgError(`"kind" must be one of ${KINDS2.join(", ")}`);
6090
6123
  const action = optional3(args, "action");
6091
6124
  const apply = optional3(args, "apply");
6092
6125
  const key = optional3(args, "key");
@@ -6155,12 +6188,12 @@ var artifactJobTools = [
6155
6188
  const answer = optional3(args, "answer");
6156
6189
  const text = args.text;
6157
6190
  if (text !== undefined && typeof text !== "string")
6158
- throw new Error('"text" must be a string');
6191
+ throw new ToolArgError('"text" must be a string');
6159
6192
  if (typeof text === "string" && Buffer.byteLength(text, "utf8") > MCP_JOB_TEXT_BYTES)
6160
- throw new Error(`"text" is larger than ${MCP_JOB_TEXT_BYTES} bytes`);
6193
+ throw new ToolArgError(`"text" is larger than ${MCP_JOB_TEXT_BYTES} bytes`);
6161
6194
  const keep = args.keep;
6162
6195
  if (keep !== undefined && (!Array.isArray(keep) || !keep.every((k) => typeof k === "string")))
6163
- throw new Error('"keep" must be a list of paths');
6196
+ throw new ToolArgError('"keep" must be a list of paths');
6164
6197
  const path = optional3(args, "path");
6165
6198
  const entry = optional3(args, "entry");
6166
6199
  const result = error ? { error } : text !== undefined || keep !== undefined ? {
@@ -6207,7 +6240,7 @@ var UPDATES = TEAM_INTAKE_STATUS_INPUTS;
6207
6240
  var need3 = (args, key) => {
6208
6241
  const value = args[key];
6209
6242
  if (typeof value !== "string" || !value.trim())
6210
- throw new Error(`"${key}" is required (a non-empty string)`);
6243
+ throw new ToolArgError(`"${key}" is required (a non-empty string)`);
6211
6244
  return value;
6212
6245
  };
6213
6246
  var optional4 = (args, key) => {
@@ -6215,7 +6248,7 @@ var optional4 = (args, key) => {
6215
6248
  if (value === undefined || value === null)
6216
6249
  return;
6217
6250
  if (typeof value !== "string")
6218
- throw new Error(`"${key}" must be a string`);
6251
+ throw new ToolArgError(`"${key}" must be a string`);
6219
6252
  return value.trim() ? value : undefined;
6220
6253
  };
6221
6254
  var strings2 = (args, key) => {
@@ -6223,13 +6256,13 @@ var strings2 = (args, key) => {
6223
6256
  if (value === undefined || value === null)
6224
6257
  return;
6225
6258
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
6226
- throw new Error(`"${key}" must be a list of strings`);
6259
+ throw new ToolArgError(`"${key}" must be a list of strings`);
6227
6260
  return value;
6228
6261
  };
6229
6262
  var oneOf4 = (args, key, allowed) => {
6230
6263
  const value = optional4(args, key);
6231
6264
  if (value !== undefined && !allowed.includes(value))
6232
- throw new Error(`"${key}" must be one of ${allowed.join(", ")}`);
6265
+ throw new ToolArgError(`"${key}" must be one of ${allowed.join(", ")}`);
6233
6266
  return value;
6234
6267
  };
6235
6268
  function dueOf(args) {
@@ -6238,7 +6271,7 @@ function dueOf(args) {
6238
6271
  return;
6239
6272
  const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
6240
6273
  if (!Number.isSafeInteger(ms) || ms <= 0)
6241
- throw new Error(`"due" must be an ISO date/time or epoch milliseconds`);
6274
+ throw new ToolArgError(`"due" must be an ISO date/time or epoch milliseconds`);
6242
6275
  return ms;
6243
6276
  }
6244
6277
  var line4 = (i) => `${i.id} ${i.status}${i.progress ? `/${i.progress}` : ""} ${i.priority} r${i.revision} ${sanitizeText(i.title).replace(/\s+/g, " ")}`;
@@ -6325,10 +6358,10 @@ ${intakeText(intake)}`;
6325
6358
  run: async (hub, args, signal) => {
6326
6359
  const status = oneOf4(args, "status", UPDATES);
6327
6360
  if (!status)
6328
- throw new Error(`"status" is required: one of ${UPDATES.join(", ")}`);
6361
+ throw new ToolArgError(`"status" is required: one of ${UPDATES.join(", ")}`);
6329
6362
  const expectedRevision = args.expectedRevision;
6330
6363
  if (expectedRevision !== undefined && expectedRevision !== null && (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0))
6331
- throw new Error(`"expectedRevision" must be the revision you read (or left out)`);
6364
+ throw new ToolArgError(`"expectedRevision" must be the revision you read (or left out)`);
6332
6365
  const reason = optional4(args, "reason");
6333
6366
  const { intake, assignment } = await reportIntakeStatus(hub.teams, need3(args, "team"), need3(args, "intake"), { status, ...typeof expectedRevision === "number" ? { expectedRevision } : {}, ...reason ? { reason } : {} }, { signal });
6334
6367
  return `${intake.id} is ${intake.status}${intake.progress ? ` (${intake.progress})` : ""}, revision ${intake.revision}${assignment ? `; assignment ${assignment.id} ${assignment.status}` : ""}`;
@@ -6421,9 +6454,7 @@ var LAUNCH = {
6421
6454
  required: ["workspace"]
6422
6455
  };
6423
6456
  var SCOPES = "App tokens need scope teams:read (reads) or teams:manage (changes); seats, status and staffing also need sessions:read. Session tokens read only teams they are seated in and never change definitions. Team roles confer no authority.";
6424
-
6425
- class ArgError extends Error {
6426
- }
6457
+ var ArgError = ToolArgError;
6427
6458
  var need4 = (args, key) => {
6428
6459
  const value = args[key];
6429
6460
  if (typeof value !== "string" || !value.trim())
@@ -6790,10 +6821,7 @@ var META_FIELDS = {
6790
6821
  projects: { ...STRINGS2, description: "Project names the profile is meant for (≤ 16)." }
6791
6822
  };
6792
6823
  var SCOPES2 = "App tokens need scope profiles:read (reads) or profiles:manage (changes; profile_version_create with content fields and the skill/command set/remove tools read the latest version first, so they also need profiles:read); pins need profiles:assign and sessions:read (an app also needs a prompt grant on the session). Session tokens never change profiles. A profile confers no authority.";
6793
-
6794
- class ProfileArgError extends Error {
6795
- }
6796
- var ArgError2 = ProfileArgError;
6824
+ var ArgError2 = ToolArgError;
6797
6825
  var need5 = (args, key) => {
6798
6826
  const value = args[key];
6799
6827
  if (typeof value !== "string" || !value.trim())
@@ -7048,7 +7076,7 @@ var STRINGS3 = { type: "array", items: { type: "string" } };
7048
7076
  var need6 = (args, key) => {
7049
7077
  const value = args[key];
7050
7078
  if (typeof value !== "string" || !value.trim())
7051
- throw new Error(`"${key}" is required (a non-empty string)`);
7079
+ throw new ToolArgError(`"${key}" is required (a non-empty string)`);
7052
7080
  return value;
7053
7081
  };
7054
7082
  var flag = (args, key, fallback) => {
@@ -7056,7 +7084,7 @@ var flag = (args, key, fallback) => {
7056
7084
  if (value === undefined || value === null)
7057
7085
  return fallback;
7058
7086
  if (typeof value !== "boolean")
7059
- throw new Error(`"${key}" must be true or false`);
7087
+ throw new ToolArgError(`"${key}" must be true or false`);
7060
7088
  return value;
7061
7089
  };
7062
7090
  var UPDATE_FIELDS = ["autoActive", "activeWithinHours", "usePins", "sessions", "projects", "labels", "muted", "mutedProjects"];
@@ -7088,10 +7116,10 @@ var watchlistTools = [
7088
7116
  run: async (hub, args, signal) => {
7089
7117
  const expected = args.expectedRevision;
7090
7118
  if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0)
7091
- throw new Error(`"expectedRevision" is required (the revision you read)`);
7119
+ throw new ToolArgError(`"expectedRevision" is required (the revision you read)`);
7092
7120
  const unknown = Object.keys(args).filter((k) => k !== "expectedRevision" && !UPDATE_FIELDS.includes(k));
7093
7121
  if (unknown.length)
7094
- throw new Error(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`);
7122
+ throw new ToolArgError(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`);
7095
7123
  const patch = {};
7096
7124
  for (const field of UPDATE_FIELDS)
7097
7125
  if (args[field] !== undefined && args[field] !== null)
@@ -7137,9 +7165,7 @@ ${watchlistConfigText(r.config)}`;
7137
7165
  // packages/cli/src/mcp.ts
7138
7166
  var MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
7139
7167
  var RPC = { parse: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602, internal: -32603 };
7140
-
7141
- class ArgError3 extends Error {
7142
- }
7168
+ var ArgError3 = ToolArgError;
7143
7169
  var need7 = (args, key) => {
7144
7170
  const value = args[key];
7145
7171
  if (typeof value !== "string" || !value.trim())
@@ -8324,7 +8350,7 @@ function createMcpServer(options) {
8324
8350
  } catch (cause) {
8325
8351
  if (controller.signal.aborted)
8326
8352
  return;
8327
- const text = cause instanceof ArgError3 || cause instanceof ProfileArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`;
8353
+ const text = cause instanceof ToolArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`;
8328
8354
  return ok2(id, toolResult(clean(text, token), true));
8329
8355
  } finally {
8330
8356
  if (inFlight.get(key(id)) === controller)
@@ -8761,7 +8787,7 @@ import { arch as osArch, platform as osPlatform } from "node:os";
8761
8787
  // packages/node/package.json
8762
8788
  var package_default2 = {
8763
8789
  name: "@huynle/hub-node",
8764
- version: "0.8.1",
8790
+ version: "0.8.2",
8765
8791
  private: true,
8766
8792
  description: "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
8767
8793
  license: "MIT",
@@ -9853,7 +9879,7 @@ import path13 from "node:path";
9853
9879
  // packages/runtime-openshell/package.json
9854
9880
  var package_default3 = {
9855
9881
  name: "@huynle/hub-runtime-openshell",
9856
- version: "0.8.1",
9882
+ version: "0.8.2",
9857
9883
  private: true,
9858
9884
  description: "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
9859
9885
  license: "MIT",
@@ -11395,6 +11421,15 @@ function wsURL2(server) {
11395
11421
  var defaultSocket2 = (url, headers) => new WebSocket(url, { headers });
11396
11422
  var message2 = (cause) => cause instanceof Error ? cause.message : String(cause);
11397
11423
 
11424
+ class CodedError extends Error {
11425
+ code;
11426
+ constructor(message, code) {
11427
+ super(message);
11428
+ this.code = code;
11429
+ this.name = "CodedError";
11430
+ }
11431
+ }
11432
+
11398
11433
  class SpokeConnection {
11399
11434
  options;
11400
11435
  state = "off";
@@ -11564,7 +11599,7 @@ class SpokeConnection {
11564
11599
  try {
11565
11600
  reply = { t: "res", id, ok: true, result: await this.options.handle(req) };
11566
11601
  } catch (cause) {
11567
- reply = { t: "res", id, ok: false, error: message2(cause) };
11602
+ reply = { t: "res", id, ok: false, error: message2(cause), ...cause instanceof CodedError ? { code: cause.code } : {} };
11568
11603
  }
11569
11604
  this.send(reply);
11570
11605
  }
@@ -13528,7 +13563,7 @@ import path22 from "node:path";
13528
13563
  // packages/claude-code/package.json
13529
13564
  var package_default4 = {
13530
13565
  name: "@huynle/hub-claude-code",
13531
- version: "0.8.1",
13566
+ version: "0.8.2",
13532
13567
  private: true,
13533
13568
  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).",
13534
13569
  license: "MIT",
@@ -13817,7 +13852,7 @@ import path25 from "node:path";
13817
13852
  // packages/preset/package.json
13818
13853
  var package_default5 = {
13819
13854
  name: "@huynle/hub-preset",
13820
- version: "0.8.1",
13855
+ version: "0.8.2",
13821
13856
  private: true,
13822
13857
  description: "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
13823
13858
  license: "MIT",
@@ -14342,7 +14377,7 @@ import { openSync as openSync5, closeSync as closeSync5, readFileSync as readFil
14342
14377
  // packages/codex/package.json
14343
14378
  var package_default6 = {
14344
14379
  name: "@huynle/hub-codex",
14345
- version: "0.8.1",
14380
+ version: "0.8.2",
14346
14381
  private: true,
14347
14382
  description: "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
14348
14383
  license: "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
6
  "license": "MIT",