@huynle/hub 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +36 -8
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/sessions.ts +4 -1
  6. package/packages/cli/dist/hub-cli.js +851 -466
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +12 -7
  9. package/packages/cli/src/decisions.ts +52 -4
  10. package/packages/cli/src/format.ts +9 -0
  11. package/packages/cli/src/grants-mcp.ts +65 -0
  12. package/packages/cli/src/mcp-operations.ts +49 -0
  13. package/packages/cli/src/mcp.ts +26 -4
  14. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  15. package/packages/cli/src/team-intake-mcp.ts +2 -1
  16. package/packages/cli/src/teams-mcp.ts +9 -3
  17. package/packages/codex/package.json +1 -1
  18. package/packages/codex/src/sessions.ts +14 -3
  19. package/packages/node/package.json +1 -1
  20. package/packages/preset/package.json +1 -1
  21. package/packages/protocol/package.json +1 -1
  22. package/packages/protocol/schema/api/v1/_root.json +119 -0
  23. package/packages/protocol/schema/api/v1/account.json +419 -0
  24. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  25. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  26. package/packages/protocol/schema/api/v1/events.json +679 -0
  27. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  28. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  29. package/packages/protocol/schema/api/v1/speech.json +492 -0
  30. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  31. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  32. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  33. package/packages/protocol/src/api-v1.ts +8 -0
  34. package/packages/protocol/src/index.ts +2 -0
  35. package/packages/protocol/src/teams.ts +16 -0
  36. package/packages/protocol/src/voice-recording.ts +39 -2
  37. package/packages/runtime-openshell/image/entry.sh +3 -1
  38. package/packages/runtime-openshell/image/start.sh +3 -1
  39. package/packages/runtime-openshell/package.json +1 -1
  40. package/packages/runtime-sdk/package.json +1 -1
  41. package/packages/sdk/package.json +1 -1
  42. package/packages/sdk/src/app-front.ts +2 -2
  43. package/packages/sdk/src/ask-fields.ts +76 -0
  44. package/packages/sdk/src/client.ts +24 -2
  45. package/packages/sdk/src/envelope.ts +3 -2
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/team-intake.ts +6 -0
  48. package/packages/sdk/src/teams.ts +4 -0
  49. package/packages/sdk/src/types.ts +36 -4
  50. package/packages/sdk/src/wake.ts +9 -4
  51. package/packages/spoke-sdk/package.json +1 -1
  52. package/src/central/agent/agent-limits.ts +185 -0
  53. package/src/central/agent/app-decisions.ts +6 -5
  54. package/src/central/agent/decisions.ts +136 -9
  55. package/src/central/agent/delivery.ts +164 -22
  56. package/src/central/agent/errors.ts +2 -1
  57. package/src/central/agent/events.ts +20 -2
  58. package/src/central/agent/grant-routes.ts +14 -1
  59. package/src/central/agent/grants.ts +23 -0
  60. package/src/central/agent/launch-engine.ts +9 -6
  61. package/src/central/agent/message-limits.ts +6 -0
  62. package/src/central/agent/message-routes.ts +33 -6
  63. package/src/central/agent/messages.ts +7 -0
  64. package/src/central/agent/placement.ts +4 -2
  65. package/src/central/agent/read-routes.ts +5 -0
  66. package/src/central/agent/routes.ts +13 -0
  67. package/src/central/agent/subscriptions.ts +15 -4
  68. package/src/central/api-v1.ts +115 -0
  69. package/src/central/artifacts/store.ts +4 -2
  70. package/src/central/concierge/board-results.ts +2 -1
  71. package/src/central/concierge/notifier.ts +4 -2
  72. package/src/central/config.ts +24 -3
  73. package/src/central/health/classify.ts +209 -0
  74. package/src/central/http.ts +380 -36
  75. package/src/central/recording/export.ts +57 -3
  76. package/src/central/recording/index.ts +236 -17
  77. package/src/central/recording/ingest.ts +11 -2
  78. package/src/central/recording/routes.ts +61 -7
  79. package/src/central/recording/schema.ts +68 -3
  80. package/src/central/recording/settings.ts +19 -5
  81. package/src/central/recording/store.ts +19 -0
  82. package/src/central/recording/taps.ts +45 -1
  83. package/src/central/registry.ts +34 -3
  84. package/src/central/speech/openai.ts +4 -4
  85. package/src/central/speech/openrouter.ts +4 -4
  86. package/src/central/speech/provider.ts +32 -1
  87. package/src/central/speech/service.ts +6 -5
  88. package/src/central/teams/budgets.ts +144 -0
  89. package/src/central/teams/hop-stalls.ts +80 -0
  90. package/src/central/teams/intake.ts +38 -6
  91. package/src/central/teams/involvement.ts +95 -0
  92. package/src/central/teams/item-decisions.ts +60 -0
  93. package/src/central/teams/item-eligibility.ts +35 -0
  94. package/src/central/teams/item-stalls.ts +87 -0
  95. package/src/central/teams/loops.ts +40 -0
  96. package/src/central/teams/policy.ts +10 -0
  97. package/src/central/teams/progress.ts +154 -0
  98. package/src/central/teams/routes.ts +2 -0
  99. package/src/central/teams/seats.ts +3 -3
  100. package/src/central/teams/staffing.ts +5 -0
  101. package/src/central/teams/store.ts +20 -1
  102. package/src/central/teams/validation.ts +16 -2
  103. package/src/central/workspaces/coordinator.ts +3 -1
  104. package/src/spoke/hub-tool.ts +26 -11
  105. package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
  106. package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
  107. package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  108. package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
  109. package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
  110. package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
  111. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  112. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  113. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  114. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  115. package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
  116. package/web/dist/assets/live-B2IfKm37.js +5 -0
  117. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  118. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  119. package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
  120. package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
  121. package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
  122. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  123. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  124. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  125. package/web/dist/index.html +5 -5
  126. package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
  127. package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
  128. package/web/dist/assets/index-bX8B6NLo.js +0 -33
  129. package/web/dist/assets/live-BGLA-peO.js +0 -5
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
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",
@@ -82,19 +82,22 @@ Identity
82
82
  hub role release <role[.project]> [--session <addr>]
83
83
 
84
84
  Messaging (text "-" reads it from stdin)
85
- hub msg <addr> "text" [--all] [--no-wake] [--urgent] [--not-after <ms|ISO time>] [--key K]
85
+ hub msg <addr> "text" [--all] [--no-wake] [--urgent] [--item itk_…] [--not-after <ms|ISO time>] [--key K]
86
86
  tell: fire and forget; --not-after: never delivered after that time
87
- hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--not-after <ms|ISO time>] [--all] [--no-wake] [--urgent] [--key K]
87
+ hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--item itk_…] [--not-after <ms|ISO time>] [--all] [--no-wake] [--urgent] [--key K]
88
88
  tracked until answered, declined or expired; --wait alone waits until
89
89
  it expires; exit 0 answered, 1 declined or no answer yet, 8 expired;
90
90
  --key (Idempotency-Key): a retry with the same key and text answers
91
- the first send again and sends nothing (24 h, per credential)
91
+ the first send again and sends nothing (24 h, per credential);
92
+ --item: open team intake item, both members seated (older hubs ignore it)
92
93
  hub reply <msgID> "text" [--no-wake] goes to the real sender
93
94
  hub decline <msgID> --why "reason"
94
95
  hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
95
96
  [--deadline 2h [--on-timeout <safe id>]] [--tier confirm|tap] [--about <addr>]...
97
+ [--choice "D1=Label:a=Option*,b=Option"]... [--kind question|plan|merge|release]
96
98
  opens a Decision for the user (answered in the console; the answer
97
- comes back as a reply); --on-timeout picks a safe option at the deadline
99
+ comes back as a reply); --on-timeout picks a safe option at the deadline;
100
+ --choice (instead of --option) asks several points, * marks the recommendation
98
101
  hub decisions list [--state open|answered|expired|closed] the Decisions you asked
99
102
  hub inbox asks to answer, your open asks, recent messages, mutes
100
103
  hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
@@ -292,15 +295,17 @@ const whyOf = (flags: Parsed["flags"]) => {
292
295
  return why !== undefined ? { why } : {}
293
296
  }
294
297
 
295
- /** --no-wake, --urgent, --not-after and --key as SDK send options. */
298
+ /** --no-wake, --urgent, --not-after, --item and --key as SDK send options. */
296
299
  const sendFlags = (flags: Parsed["flags"]) => {
297
300
  const notAfter = str(flags, "not-after")
298
301
  const key = str(flags, "key")
302
+ const item = str(flags, "item")
299
303
  return {
300
304
  ...(flags["no-wake"] === true ? { wake: false } : {}),
301
305
  ...(flags.urgent === true ? { urgent: true } : {}),
302
306
  ...(notAfter === undefined ? {} : { notAfter: notAfterArg(notAfter, "--not-after") }),
303
307
  ...(key !== undefined ? { idempotencyKey: key } : {}),
308
+ ...(item !== undefined ? { item } : {}),
304
309
  }
305
310
  }
306
311
 
@@ -422,7 +427,7 @@ const COMMANDS: Record<string, Command> = {
422
427
  },
423
428
  msg: {
424
429
  args: ["<addr>", "<text>"],
425
- flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", key: "string" },
430
+ flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", item: "string", key: "string" },
426
431
  async handle(context) {
427
432
  const { flags, args } = context.parsed
428
433
  const text = await textArg(args[1]!, context.io.stdin)
@@ -433,7 +438,7 @@ const COMMANDS: Record<string, Command> = {
433
438
  },
434
439
  ask: {
435
440
  args: ["<addr>", "<text>"],
436
- flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", key: "string", expires: "string", wait: "optional", ...DECISION_FLAGS },
441
+ flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", item: "string", key: "string", expires: "string", wait: "optional", ...DECISION_FLAGS },
437
442
  async handle(context) {
438
443
  const { flags, args } = context.parsed
439
444
  const decision = decisionOptions(flags, args[0]!)
@@ -1,7 +1,8 @@
1
1
  // Decisions in the CLI (design §12.1): the `hub ask @me` options (--option id=label[:safe], --recommend id --why,
2
- // --urgency, --deadline, --on-timeout, --tier, --about) and the text of a Decision (ask @me, `hub decisions list`).
2
+ // --urgency, --deadline, --on-timeout, --tier, --about; DC-4: --choice 'D1=Label:a=Yes*,b=No' and --kind) and the
3
+ // text of a Decision (ask @me, `hub decisions list`).
3
4
  // The shapes are packages/sdk's types (src/central/agent/decisions.ts).
4
- import type { AskOptions, Decision, DecisionOption, DecisionState } from "../../sdk/src/index.ts"
5
+ import type { AskOptions, Decision, DecisionChoice, DecisionKind, DecisionOption, DecisionState } from "../../sdk/src/index.ts"
5
6
  import { str, UsageError, type FlagKind, type Parsed } from "./args"
6
7
  import { oneLine } from "./format"
7
8
  import { durationArg, partyText } from "./messaging"
@@ -16,9 +17,12 @@ export const DECISION_FLAGS: Readonly<Record<string, FlagKind>> = {
16
17
  "on-timeout": "string",
17
18
  tier: "string",
18
19
  about: "multi",
20
+ choice: "multi",
21
+ kind: "string",
19
22
  }
20
23
 
21
24
  const URGENCIES = ["low", "normal", "high"]
25
+ const KINDS: readonly DecisionKind[] = ["question", "plan", "merge", "release"]
22
26
  const TIERS = ["confirm", "tap"]
23
27
  const STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
24
28
  export const DECISION_STATES = STATES
@@ -35,6 +39,38 @@ export function optionArg(value: string): DecisionOption {
35
39
  return { id, label, ...(safe ? { safe: true } : {}) }
36
40
  }
37
41
 
42
+ /**
43
+ * `D1=Label:a=Yes*,b=No`: a choice group, its label (it may contain colons), then its options (`id=label`, comma
44
+ * separated; a trailing `*` marks the recommended one). The hub checks ids and sizes.
45
+ */
46
+ export function choiceArg(value: string): DecisionChoice {
47
+ const usage = `--choice takes GROUP=Label:a=Option*,b=Option (got "${value}")`
48
+ const eq = value.indexOf("=")
49
+ if (eq <= 0) throw new UsageError(usage)
50
+ const id = value.slice(0, eq)
51
+ const rest = value.slice(eq + 1)
52
+ // The options start after the last colon followed by `id=`.
53
+ let split = -1
54
+ for (let i = rest.lastIndexOf(":"); i >= 0; i = rest.lastIndexOf(":", i - 1)) {
55
+ if (/^[^=,:\s][^=,:]*=/.test(rest.slice(i + 1))) {
56
+ split = i
57
+ break
58
+ }
59
+ }
60
+ if (split < 0) throw new UsageError(usage)
61
+ const label = rest.slice(0, split)
62
+ if (!label.trim()) throw new UsageError(`--choice ${id}: the label is empty`)
63
+ const options = rest.slice(split + 1).split(",").map((option) => {
64
+ const at = option.indexOf("=")
65
+ if (at <= 0) throw new UsageError(usage)
66
+ const recommended = option.endsWith("*")
67
+ const text = option.slice(at + 1, recommended ? -1 : undefined)
68
+ if (!text.trim()) throw new UsageError(`--choice ${id}: option ${option.slice(0, at)} has an empty label`)
69
+ return { id: option.slice(0, at), label: text, ...(recommended ? { recommended: true } : {}) }
70
+ })
71
+ return { id, label, options }
72
+ }
73
+
38
74
  const oneOf = (value: string | undefined, allowed: readonly string[], flag: string) => {
39
75
  if (value !== undefined && !allowed.includes(value)) throw new UsageError(`${flag} is one of ${allowed.join(", ")}`)
40
76
  return value
@@ -54,8 +90,13 @@ export function decisionOptions(flags: Parsed["flags"], to: string): Partial<Ask
54
90
  const onTimeout = str(flags, "on-timeout")
55
91
  const options = flags.option as string[] | undefined
56
92
  const about = flags.about as string[] | undefined
93
+ const choices = flags.choice as string[] | undefined
94
+ if (choices && options) throw new UsageError("--choice and --option don't mix: give one or the other")
95
+ const kind = oneOf(str(flags, "kind"), KINDS, "--kind") as DecisionKind | undefined
57
96
  return {
58
97
  ...(options ? { options: options.map(optionArg) } : {}),
98
+ ...(choices ? { choices: choices.map(choiceArg) } : {}),
99
+ ...(kind ? { kind } : {}),
59
100
  ...(recommend !== undefined ? { recommendation: { option: recommend, ...(why !== undefined ? { why } : {}) } } : {}),
60
101
  ...(urgency ? { urgency } : {}),
61
102
  ...(tier ? { tier } : {}),
@@ -72,8 +113,14 @@ export function stateArg(value: string | undefined): DecisionState | undefined {
72
113
 
73
114
  const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
74
115
 
75
- /** `go Proceed · hold Hold (safe) — recommended: hold (why)`; empty for a free-text Decision without one. */
116
+ /**
117
+ * `go Proceed · hold Hold (safe) — recommended: hold (why)`; with choices, one line per group
118
+ * (`D1 Order: a First (recommended) · b Second`); empty for a free-text Decision without one.
119
+ */
76
120
  function choicesText(decision: Decision): string {
121
+ if (decision.choices?.length) {
122
+ return decision.choices.map((g) => `${g.id} ${oneLine(g.label, 120)}: ${g.options.map((o) => `${o.id} ${oneLine(o.label, 80)}${o.recommended ? " (recommended)" : ""}`).join(" · ")}`).join("\n ")
123
+ }
77
124
  const options = decision.options.map((o) => `${o.id} ${oneLine(o.label, 80)}${o.safe ? " (safe)" : ""}`).join(" · ")
78
125
  const r = decision.recommendation
79
126
  const recommended = r ? `recommended: ${r.option}${r.why ? ` (${oneLine(r.why, 200)})` : ""}` : ""
@@ -83,7 +130,8 @@ function choicesText(decision: Decision): string {
83
130
  function stateText(decision: Decision): string {
84
131
  if (decision.state === "answered") {
85
132
  const a = decision.answer
86
- return `answered${a?.option ? ` ${a.option}` : ""}${a?.answeredBy === "timeout" ? " (timeout)" : ""}`
133
+ const picks = a?.picks ? ` ${Object.entries(a.picks).map(([group, option]) => `${group}=${option}`).join(" ")}` : ""
134
+ return `answered${a?.option ? ` ${a.option}` : ""}${picks}${a?.answeredBy === "timeout" ? " (timeout)" : ""}`
87
135
  }
88
136
  if (decision.state === "open" && decision.deadline !== undefined) return `open (deadline ${iso(decision.deadline)})`
89
137
  return decision.state
@@ -16,6 +16,8 @@ export interface SessionView {
16
16
  readonly harness?: string
17
17
  readonly now?: string
18
18
  readonly blockers?: readonly Blocker[]
19
+ /** (SS-1) Why the session is not making progress; absent: ok (or an older hub). */
20
+ readonly health?: { readonly kind: string; readonly since?: number; readonly resetAt?: number; readonly attempt?: number; readonly detail?: string }
19
21
  }
20
22
 
21
23
  const at = (name: string) => `@${name.replace(/^@+/, "")}`
@@ -94,6 +96,13 @@ export function showText(s: Shown): string {
94
96
  field("parent", s.parentID)
95
97
  if (s.roles?.length) field("roles", s.roles.map((r) => `@${at(r)}`).join(" "))
96
98
  field("now", s.now)
99
+ if (s.health && s.health.kind !== "ok") {
100
+ const h = s.health
101
+ // A hub's value is data: a resetAt that is no valid date is left out, never thrown on.
102
+ const reset = typeof h.resetAt === "number" && Number.isFinite(h.resetAt) && Math.abs(h.resetAt) <= 8.64e15 ? new Date(h.resetAt).toISOString() : undefined
103
+ const facts = [h.kind, reset ? `resets ${reset}` : undefined, h.attempt !== undefined ? `attempt ${h.attempt}` : undefined].filter(Boolean).join(", ")
104
+ field("health", h.detail ? `${facts} — ${oneLine(h.detail, 200)}` : facts)
105
+ }
97
106
  if (s.children) field("subagents", s.children)
98
107
  if (s.online === false && s.lastSeen !== undefined) field("last seen", new Date(s.lastSeen).toISOString())
99
108
  if (s.blockers?.length) {
@@ -0,0 +1,65 @@
1
+ // MCP grant tools: the server owns authority. Requests use the existing tap Decision route; /mine never widens
2
+ // the user-only grant administration routes. A user credential may create directly but has no /mine holder.
3
+ import type { HubClient } from "../../sdk/src/client.ts"
4
+ import type { GrantRequestSpec, GrantSpec } from "../../sdk/src/types.ts"
5
+ import { grantError, grantText, grantsText, GRANT_USER_HINT, requestedText } from "./grants"
6
+ import { CliError, EXIT } from "./http"
7
+ import { ToolArgError } from "./tool-args"
8
+
9
+ type Args = Record<string, unknown>
10
+ export interface GrantTool {
11
+ readonly name: string
12
+ readonly description: string
13
+ readonly properties: Record<string, unknown>
14
+ readonly required?: readonly string[]
15
+ readonly readOnly: boolean
16
+ readonly organizes?: boolean
17
+ run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
18
+ }
19
+
20
+ const STRING = { type: "string" } as const
21
+ const GRANT = { type: "object", description: "The grant spec accepted by POST /api/agent/grants: holder (optional for a request), scope.projects, allow, permissions, allowAlways, maxActionsPerHour and expires. The server validates all fields.", additionalProperties: true } as const
22
+ const need = (args: Args, key: string): string => {
23
+ if (typeof args[key] !== "string" || !(args[key] as string).trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
24
+ return args[key] as string
25
+ }
26
+ const spec = (args: Args): Record<string, unknown> => {
27
+ if (typeof args.grant !== "object" || args.grant === null || Array.isArray(args.grant)) throw new ToolArgError('"grant" is required (an object)')
28
+ return args.grant as Record<string, unknown>
29
+ }
30
+
31
+ export const grantTools: readonly GrantTool[] = [
32
+ {
33
+ name: "grant_request", description: "Ask the user for a grant: opens one tap Decision for @me (approve or deny). Sessions and apps with messages:send only; one open request and at most five per hour. Only the user's answer creates authority.",
34
+ properties: { grant: GRANT, why: STRING }, required: ["grant", "why"], readOnly: false,
35
+ run: async (hub, args, signal) => requestedText(await hub.requestGrant(spec(args) as GrantRequestSpec, need(args, "why"), { signal })),
36
+ },
37
+ {
38
+ name: "grant_list", description: "List grants (active, expired or revoked) whose holder is this session or app, including grants for roles it holds now. Apps need messages:read. A user credential has no holder: use the console or hub grant list for administration.",
39
+ properties: {}, readOnly: true,
40
+ run: async (hub, _args, signal) => grantsText((await hub.heldGrants({ signal })).grants),
41
+ },
42
+ {
43
+ name: "grant_show", description: "Show one grant only if this credential currently holds it. Apps need messages:read; another holder's ID answers not_found.",
44
+ properties: { id: STRING }, required: ["id"], readOnly: true,
45
+ run: async (hub, args, signal) => grantText((await hub.heldGrant(need(args, "id"), { signal })).grant),
46
+ },
47
+ {
48
+ name: "grant_revoke", description: "Irreversibly revoke a grant held by this session or app (including a held role shared with other holders). Apps need messages:send; another holder's ID answers not_found.",
49
+ properties: { id: STRING }, required: ["id"], readOnly: false,
50
+ run: async (hub, args, signal) => grantText((await hub.revokeHeldGrant(need(args, "id"), { signal })).grant),
51
+ },
52
+ {
53
+ name: "grant_create", description: `Create a grant directly ONLY with the user's device credential. Sessions and apps must use grant_request and wait for the user's tap. ${GRANT_USER_HINT}.`,
54
+ properties: { grant: GRANT }, required: ["grant"], readOnly: false,
55
+ run: async (hub, args, signal) => {
56
+ const actor = (await hub.whoami({ signal })).actor
57
+ if (actor.kind !== "user") throw new CliError(EXIT.forbidden, "only the user can create a grant, not a session or app", GRANT_USER_HINT)
58
+ try {
59
+ return grantText((await hub.createGrant(spec(args) as unknown as GrantSpec, { signal })).grant)
60
+ } catch (cause) {
61
+ throw grantError(cause)
62
+ }
63
+ },
64
+ },
65
+ ]
@@ -0,0 +1,49 @@
1
+ // User-only MCP access to the existing console session-model route and owner-scoped machine/node snapshots.
2
+ // No agent or app model-setting authority: the server's model route remains console user-only.
3
+ import type { HubClient } from "../../sdk/src/client.ts"
4
+ import { CliError, EXIT } from "./http"
5
+ import { ToolArgError } from "./tool-args"
6
+
7
+ type Args = Record<string, unknown>
8
+ interface Tool {
9
+ readonly name: string
10
+ readonly description: string
11
+ readonly properties: Record<string, unknown>
12
+ readonly required?: readonly string[]
13
+ readonly readOnly: boolean
14
+ run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
15
+ }
16
+ const STRING = { type: "string" } as const
17
+ const need = (args: Args, key: string) => {
18
+ const v = args[key]
19
+ if (typeof v !== "string" || !v.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
20
+ return v
21
+ }
22
+ const userOnly = async (hub: HubClient, signal: AbortSignal) => {
23
+ if ((await hub.whoami({ signal })).actor.kind !== "user") throw new CliError(EXIT.forbidden, "only the user can use this tool", "Use the user's device credential")
24
+ }
25
+
26
+ export const operationTools: readonly Tool[] = [
27
+ {
28
+ name: "session_model_set", description: "Set one existing session's model (providerID, modelID and optional variant), exactly as the console Model chip does. User device credential only; the console route checks ownership, catalog, availability and audit. An app or session cannot set it.",
29
+ properties: { session: STRING, model: { type: "object", properties: { providerID: STRING, modelID: STRING, variant: STRING }, required: ["providerID", "modelID"] } },
30
+ required: ["session", "model"], readOnly: false,
31
+ run: async (hub, args, signal) => {
32
+ await userOnly(hub, signal)
33
+ const model = args.model
34
+ if (typeof model !== "object" || model === null || Array.isArray(model)) throw new ToolArgError('"model" is required (providerID, modelID, variant?)')
35
+ const result = await hub.call<{ model: unknown }>("PUT", `/api/sessions/${encodeURIComponent(need(args, "session"))}/model`, { body: { model }, signal })
36
+ return JSON.stringify(result)
37
+ },
38
+ },
39
+ {
40
+ name: "machines", description: "The user's own enrolled machines, online status and processes. User device credential only; wraps GET /api/machines. No machine tokens or secrets.",
41
+ properties: {}, readOnly: true,
42
+ run: async (hub, _args, signal) => { await userOnly(hub, signal); return JSON.stringify(await hub.call("GET", "/api/machines", { signal })) },
43
+ },
44
+ {
45
+ name: "launch_nodes", description: "The user's connected launch nodes: machine, workspace roots, capacity/used/free slots, runtimes (harnesses) and labels. User device credential only; no tokens, connection IDs or mutation.",
46
+ properties: {}, readOnly: true,
47
+ run: async (hub, _args, signal) => { await userOnly(hub, signal); return JSON.stringify(await hub.call("GET", "/api/agent/launch-nodes", { signal })) },
48
+ },
49
+ ]
@@ -11,7 +11,7 @@
11
11
  // the profile lifecycle (profile_show, profile_create, profile_update, profile_version_create, profile_skill_set, …,
12
12
  // profile_assign; scopes profiles:read|manage|assign: profiles-mcp.ts), and the Concierge watchlist (watchlist_get,
13
13
  // watchlist_update, watchlist_watch, watchlist_mute: watchlist.ts).
14
- import { renderEnvelope, sanitizeText, WAIT_POLL_MAX_S, type AgentMessage, type Asked, type DecisionState, type FormAnswer, type HubClient, type LaunchSpec, type LaunchState, type PromptDelivery, type Inbox, type Sent, type SentAll } from "../../sdk/src/index.ts"
14
+ import { DECISION_ASK_PROPERTIES, decisionAskFields, renderEnvelope, sanitizeText, WAIT_POLL_MAX_S, type AgentMessage, type Asked, type DecisionState, type FormAnswer, type HubClient, type LaunchSpec, type LaunchState, type PromptDelivery, type Inbox, type Sent, type SentAll } from "../../sdk/src/index.ts"
15
15
  import { answeredText, approvedText, DELIVERIES, deniedText, needsUserLines, promptedText, stoppedText } from "./acting"
16
16
  import { UsageError } from "./args"
17
17
  import { intentLine, writeIntent } from "./intents"
@@ -25,8 +25,11 @@ import { launchedText, launchesText, launchText, stoppedLaunchText } from "./lau
25
25
  import { artifactTools } from "./artifacts-mcp"
26
26
  import { artifactJobTools } from "./artifact-jobs-mcp"
27
27
  import { teamIntakeTools } from "./team-intake-mcp"
28
+ import { subscriptionTools } from "./subscriptions-mcp"
29
+ import { operationTools } from "./mcp-operations"
28
30
  import { teamTools } from "./teams-mcp"
29
31
  import { profileLifecycleTools } from "./profiles-mcp"
32
+ import { grantTools } from "./grants-mcp"
30
33
  import { ToolArgError } from "./tool-args"
31
34
  import { watchlistTools } from "./watchlist"
32
35
 
@@ -123,7 +126,8 @@ const sendOptions = (args: Args) => {
123
126
  const wake = optionalBool(args, "wake")
124
127
  const urgent = optionalBool(args, "urgent")
125
128
  const notAfter = optionalNotAfter(args)
126
- return { ...(wake !== undefined ? { wake } : {}), ...(urgent !== undefined ? { urgent } : {}), ...(notAfter !== undefined ? { notAfter } : {}) }
129
+ const item = optional(args, "item")
130
+ return { ...(wake !== undefined ? { wake } : {}), ...(urgent !== undefined ? { urgent } : {}), ...(notAfter !== undefined ? { notAfter } : {}), ...(item !== undefined ? { item } : {}) }
127
131
  }
128
132
 
129
133
  /** The why argument as SDK act options. */
@@ -235,6 +239,7 @@ const DECISIONS_NOTE = "Decisions are asked by sessions: the question and option
235
239
 
236
240
  const STRING = { type: "string" } as const
237
241
  const SEND = {
242
+ item: { type: "string", description: "msg/ask: optional itk_ ID of an open item; sender and recipient must hold current seats in the item's team (older hubs ignore it)." },
238
243
  wake: { type: "boolean", description: "false: admit the message without starting a turn." },
239
244
  urgent: { type: "boolean", description: "Steer into the recipient's running turn." },
240
245
  notAfter: { type: ["number", "string"], description: "Never deliver after this time: epoch milliseconds or an ISO 8601 date-time (at most 30 days ahead). Already passed: nothing is sent (expired)." },
@@ -755,12 +760,22 @@ const TOOLS: readonly Tool[] = [
755
760
  ...webhookTools,
756
761
  ...profileTools,
757
762
  ...profileLifecycleTools,
763
+ ...grantTools,
758
764
  ...workspaceTools,
759
765
  ...artifactTools,
760
766
  ...artifactJobTools,
761
767
  ...teamIntakeTools,
768
+ ...subscriptionTools,
769
+ ...operationTools,
762
770
  ...teamTools,
763
771
  ...watchlistTools,
772
+ {
773
+ name: "agent_limits",
774
+ description: "Read the hub-wide agent hop limit, the hop ceiling (the highest hop limit the hub accepts) and launch caps, their sources and revision. Read-only; only an admin may change them in the console.",
775
+ properties: {},
776
+ readOnly: true,
777
+ run: async (hub, _args, signal) => JSON.stringify(await hub.call("GET", "/api/agent/agent-limits", { signal }), null, 2),
778
+ },
764
779
  {
765
780
  name: "whoami",
766
781
  description: "Who this hub credential is: the app token of `hub login` (or the session of HUB_SESSION_TOKEN) and its user.",
@@ -840,10 +855,11 @@ const TOOLS: readonly Tool[] = [
840
855
  },
841
856
  {
842
857
  name: "ask",
843
- description: `Ask a session a question, tracked until answered, declined or expired. wait (at most ${WAIT_POLL_MAX_S} s) blocks for the answer; otherwise it shows up in the inbox tool.`,
858
+ description: `Ask a session a question, tracked until answered, declined or expired. wait (at most ${WAIT_POLL_MAX_S} s) blocks for the answer; otherwise it shows up in the inbox tool. To @me it opens a Decision for the user: give options (buttons) with a recommendation, or choices (several points in one ask), and urgency, tier and kind.`,
844
859
  properties: {
845
860
  to: TO,
846
861
  text: { ...STRING, description: "The question." },
862
+ ...DECISION_ASK_PROPERTIES,
847
863
  wait: { type: "number", minimum: 0, maximum: WAIT_POLL_MAX_S, description: `Seconds to wait for the answer (0–${WAIT_POLL_MAX_S}).` },
848
864
  expires: { type: ["number", "string"], description: "When the ask expires: seconds, or 30s, 5m, 2h, 1d (1 minute to 7 days; default 24 h)." },
849
865
  ...SEND,
@@ -856,7 +872,13 @@ const TOOLS: readonly Tool[] = [
856
872
  const text = need(args, "text")
857
873
  const wait = optionalWait(args)
858
874
  const expires = optionalExpires(args)
859
- const options = { ...sendOptions(args), ...(expires !== undefined ? { expires } : {}), ...idemOption(args), signal }
875
+ let decision: ReturnType<typeof decisionAskFields>
876
+ try {
877
+ decision = decisionAskFields(args)
878
+ } catch (cause) {
879
+ throw new ArgError((cause as Error).message)
880
+ }
881
+ const options = { ...sendOptions(args), ...(expires !== undefined ? { expires } : {}), ...idemOption(args), ...decision, signal }
860
882
  if (optionalBool(args, "all")) {
861
883
  if (wait !== undefined) throw new ArgError(`"wait" cannot be combined with "all"`)
862
884
  return sentAllText(await hub.ask(to, text, { ...options, all: true }))
@@ -0,0 +1,76 @@
1
+ // MCP wrappers over the existing subscription routes; authorization and delivery caps remain on the server.
2
+ import type { HubClient } from "../../sdk/src/client.ts"
3
+ import type { SubscribeInput } from "../../sdk/src/types.ts"
4
+ import { subscribedText, subscriptionsText } from "./subscriptions"
5
+ import { ToolArgError } from "./tool-args"
6
+
7
+ type Args = Record<string, unknown>
8
+ export interface SubscriptionTool {
9
+ readonly name: string
10
+ readonly description: string
11
+ readonly properties: Record<string, unknown>
12
+ readonly required?: readonly string[]
13
+ readonly readOnly: boolean
14
+ readonly organizes?: boolean
15
+ run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
16
+ }
17
+ const STRING = { type: "string" } as const
18
+ const need = (args: Args, key: string): string => {
19
+ const value = args[key]
20
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
21
+ return value
22
+ }
23
+ const optional = (args: Args, key: string): string | undefined => {
24
+ const value = args[key]
25
+ if (value === undefined || value === null) return undefined
26
+ if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
27
+ return value.trim() || undefined
28
+ }
29
+ const array = (args: Args, key: string): string[] | undefined => {
30
+ const value = args[key]
31
+ if (value === undefined || value === null) return undefined
32
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ToolArgError(`"${key}" must be a list of strings`)
33
+ return value
34
+ }
35
+ const number = (args: Args, key: string, min: number, max: number): number | undefined => {
36
+ const value = args[key]
37
+ if (value === undefined || value === null) return undefined
38
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new ToolArgError(`"${key}" must be a whole number from ${min} to ${max}`)
39
+ return value
40
+ }
41
+
42
+ export const subscriptionTools: readonly SubscriptionTool[] = [
43
+ {
44
+ name: "subscribe", description: "Follow journal events for a subscriber session. A session subscribes itself; users and apps name subscriber. Apps need events:subscribe, sessions:read, relevant type scopes and messages:send unless silent. Hub checks visibility, rates and loops.",
45
+ properties: {
46
+ to: { type: ["string", "array"], items: STRING, description: "One or more session/role/team addresses, or children." },
47
+ subscriber: STRING, types: { type: "array", items: STRING }, delivery: { type: "string", enum: ["queue", "steer", "silent"] },
48
+ batch: { type: "number", description: "Seconds per batch (1–3600)." }, expires: { type: "number", description: "Seconds until expiry." },
49
+ maxPerHour: { type: "integer", minimum: 1, maximum: 120 }, note: STRING,
50
+ }, required: ["to"], readOnly: false,
51
+ run: async (hub, args, signal) => {
52
+ const to = typeof args.to === "string" && args.to.trim() ? args.to : array(args, "to")
53
+ if (!to || (Array.isArray(to) && !to.length)) throw new ToolArgError('"to" needs at least one address')
54
+ const delivery = optional(args, "delivery")
55
+ if (delivery && !["queue", "steer", "silent"].includes(delivery)) throw new ToolArgError('"delivery" is queue, steer or silent')
56
+ const subscriber = optional(args, "subscriber")
57
+ const types = array(args, "types")
58
+ const batch = number(args, "batch", 1, 3600)
59
+ const expires = number(args, "expires", 1, 30 * 86400)
60
+ const maxPerHour = number(args, "maxPerHour", 1, 120)
61
+ const note = optional(args, "note")
62
+ const input: SubscribeInput = { to, ...(subscriber ? { subscriber } : {}), ...(types ? { types } : {}), ...(delivery ? { delivery: delivery as SubscribeInput["delivery"] } : {}), ...(batch ? { batch } : {}), ...(expires ? { expires } : {}), ...(maxPerHour ? { maxPerHour } : {}), ...(note ? { note } : {}) }
63
+ return subscribedText(await hub.subscribe(input, { signal }))
64
+ },
65
+ },
66
+ {
67
+ name: "subscriptions", description: "List subscriptions visible to this credential (session: its own; app: created by it; user: all). Optional session filter; route enforces scopes and tenancy.",
68
+ properties: { session: STRING }, readOnly: true,
69
+ run: async (hub, args, signal) => subscriptionsText(await hub.subscriptions({ ...(optional(args, "session") ? { session: optional(args, "session") } : {}), signal })),
70
+ },
71
+ {
72
+ name: "unsubscribe", description: "Remove a subscription this credential owns. An unknown or another caller's ID answers not_found.",
73
+ properties: { id: STRING }, required: ["id"], readOnly: false, organizes: true,
74
+ run: async (hub, args, signal) => { const id = need(args, "id"); await hub.unsubscribe(id, { signal }); return `unsubscribed ${id}` },
75
+ },
76
+ ]
@@ -79,7 +79,7 @@ function notAfterOf(args: Args): number | undefined {
79
79
  }
80
80
 
81
81
  const line = (i: TeamIntake) =>
82
- `${i.id} ${i.status}${i.progress ? `/${i.progress}` : ""} ${i.priority} r${i.revision} ${sanitizeText(i.title).replace(/\s+/g, " ")}`
82
+ `${i.id} ${i.status}${i.progress ? `/${i.progress}` : ""} ${i.priority} r${i.revision} ${sanitizeText(i.title).replace(/\s+/g, " ")}${i.usage ? ` wakes ${i.usage.wakes}/${i.usage.budget.wakes} · ${i.usage.hours.toFixed(1)}/${i.usage.budget.hours}h` : ""}`
83
83
 
84
84
  function intakeText(i: TeamIntake): string {
85
85
  const from = i.source.kind === "user" ? "the user" : i.source.kind === "app" ? `app ${i.source.tokenID}` : `session ${i.source.sessionID}`
@@ -95,6 +95,7 @@ function intakeText(i: TeamIntake): string {
95
95
  ...(i.ref ? [`ref: ${sanitizeText(i.ref)}`] : []),
96
96
  ...(i.reason ? [`reason: ${sanitizeText(i.reason)}`] : []),
97
97
  ...(i.lastError ? [`last routing error: ${i.lastError} (retried)`] : []),
98
+ ...(i.usage ? [`usage: ${i.usage.wakes}/${i.usage.budget.wakes} wakes · ${i.usage.hours.toFixed(1)}/${i.usage.budget.hours} hours (paused and blocked time counts)${i.usage.chargedTo ? `; last charged to ${i.usage.chargedTo}` : ""}`] : []),
98
99
  ].join("\n")
99
100
  }
100
101
 
@@ -51,6 +51,9 @@ const SPEC_FIELDS = {
51
51
  title: { ...STRING, description: "1–120 characters." },
52
52
  description: STRING,
53
53
  mode: { type: "string", enum: ["open", "restricted"], description: "open: every member may msg/ask every other member. restricted: only the drawn paths (edges)." },
54
+ hopLimit: { type: "integer", minimum: 1, maximum: 10_000, description: "Agent-only wake hop limit between this team's seats; at most the hub's hop ceiling (agent_limits hopCeiling, default 100). The effective limit is the lower of this and the hub-wide hopLimit. Unset uses the hub-wide limit." },
55
+ itemBudget: { type: "object", properties: { wakes: { type: "integer", minimum: 10, maximum: 5000 }, hours: { type: "integer", minimum: 1, maximum: 720 } }, required: ["wakes", "hours"], description: "User-only per-item budget override: agent wakes and hours in progress." },
56
+ loop: { type: "object", properties: { messages: { type: "integer", minimum: 4, maximum: 200 }, windowMinutes: { type: "integer", minimum: 5, maximum: 240 } }, required: ["messages", "windowMinutes"], description: "User-only no-progress pair-message threshold and window." },
54
57
  roles: { type: "array", items: ROLE, description: "1–32 roles." },
55
58
  entryRole: { ...STRING, description: "The role that receives submitted work (e.g. pm) and supervises members." },
56
59
  edges: { type: "array", items: EDGE, description: "Communication paths between roles (restricted mode)." },
@@ -176,6 +179,9 @@ function specLines(spec: TeamSpec): string[] {
176
179
  ` ${r.name}${r.name === spec.entryRole ? " (entry)" : ""}${r.label ? ` "${one(r.label)}"` : ""}${r.externalContact ? " external contact" : ""}${r.minMembers !== undefined || r.maxMembers !== undefined ? ` members ${r.minMembers ?? 0}–${r.maxMembers ?? 16}` : ""}${r.profile ? ` profile ${r.profile.profileID}${r.profile.version ? `@${r.profile.version}` : ""}` : ""}`
177
180
  return [
178
181
  `mode: ${spec.mode}${spec.mode === "open" ? " (every member may msg/ask every other)" : " (only the paths below)"}`,
182
+ ...(spec.hopLimit !== undefined ? [`hop limit: ${spec.hopLimit}`] : []),
183
+ ...(spec.itemBudget ? [`item budget: ${spec.itemBudget.wakes} wakes, ${spec.itemBudget.hours} hours`] : []),
184
+ ...(spec.loop ? [`loop: ${spec.loop.messages} messages per ${spec.loop.windowMinutes} minutes`] : []),
179
185
  `entry role: ${spec.entryRole}`,
180
186
  "roles:",
181
187
  ...spec.roles.map(role),
@@ -196,7 +202,7 @@ function rosterLines(roster: TeamRoster): string[] {
196
202
  const flags = [r.vacant ? "vacant" : "", r.understaffed ? `understaffed (min ${r.minMembers})` : "", r.launching ? `${r.launching} launching` : ""].filter(Boolean)
197
203
  lines.push(` ${r.role}${r.entry ? " (entry)" : ""} ${r.seats.length}/${r.maxMembers} ${r.online} online${flags.length ? ` ${flags.join(", ")}` : ""}`)
198
204
  for (const s of r.seats)
199
- lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
205
+ lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence}${s.stalled ? ` stalled: ${s.stalled}` : ""} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
200
206
  }
201
207
  const failed = roster.requests.filter((q) => q.state !== "staffed").slice(-5)
202
208
  for (const q of failed) lines.push(` launch ${q.id} ${q.role} ${q.state}${q.reason ? `: ${one(q.reason)}` : ""}`)
@@ -347,7 +353,7 @@ export const teamTools: readonly TeamTool[] = [
347
353
  need(args, "name")
348
354
  need(args, "title")
349
355
  const key = optional(args, "idempotencyKey")
350
- const spec = picked(args, ["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
356
+ const spec = picked(args, ["name", "title", "description", "mode", "hopLimit", "itemBudget", "loop", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
351
357
  const { team, created } = await hub.teams.create({ ...spec, edges: spec.edges ?? [] }, { signal, ...(key ? { idempotencyKey: key } : {}) })
352
358
  return [`${created ? "created" : "already created (same key)"}: ${teamLine(team)}`, ...specLines(team)].join("\n")
353
359
  },
@@ -362,7 +368,7 @@ export const teamTools: readonly TeamTool[] = [
362
368
  idempotent: true,
363
369
  run: async (hub, args, signal) => {
364
370
  const expectedRevision = revision(args)
365
- const patch = picked(args, ["title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
371
+ const patch = picked(args, ["title", "description", "mode", "hopLimit", "itemBudget", "loop", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
366
372
  if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
367
373
  const id = need(args, "team")
368
374
  const { team } = await hub.teams.update(id, patch, expectedRevision, { signal })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "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).",
6
6
  "license": "MIT",
@@ -92,7 +92,17 @@ const RECENT_TURNS = 50
92
92
 
93
93
  /** Who an input the spoke sent is from: the hub's turn/steer ops are the user's, carried by the hub. */
94
94
  type Origin = "carried" | "hub"
95
- type TurnEnd = { readonly status: string; readonly error?: string }
95
+ type TurnEnd = { readonly status: string; readonly error?: string; readonly errorType?: string }
96
+
97
+ /**
98
+ * (SS-9) A failed turn's codexErrorInfo as error.type "codex.<snake_case>": a camelCase string ("usageLimitExceeded",
99
+ * "other") or a one-key object ({httpConnectionFailed: {httpStatusCode}}). Anything else gives no type.
100
+ */
101
+ function codexErrorType(info: unknown): string | undefined {
102
+ const name = typeof info === "string" ? info : isObj(info) && Object.keys(info).length === 1 ? Object.keys(info)[0] : undefined
103
+ if (!name || !/^[a-z][A-Za-z0-9]{0,40}$/.test(name)) return undefined
104
+ return `codex.${name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)}`
105
+ }
96
106
 
97
107
  interface TurnWaiter {
98
108
  resolve(end: TurnEnd): void
@@ -529,7 +539,8 @@ export class CodexSessions {
529
539
  if (!turnID) return
530
540
  const status = typeof turn.status === "string" ? turn.status : "completed"
531
541
  const error = isObj(turn.error) && typeof turn.error.message === "string" ? cut(turn.error.message, HARNESS_ERROR_MAX) : undefined
532
- const end: TurnEnd = { status, ...(error !== undefined ? { error } : {}) }
542
+ const errorType = isObj(turn.error) ? codexErrorType(turn.error.codexErrorInfo) : undefined
543
+ const end: TurnEnd = { status, ...(error !== undefined ? { error } : {}), ...(errorType !== undefined ? { errorType } : {}) }
533
544
  if (thread.turnID === turnID) thread.turnID = undefined
534
545
  thread.ended.set(turnID, end)
535
546
  while (thread.ended.size > RECENT_TURNS) thread.ended.delete(thread.ended.keys().next().value!)
@@ -537,7 +548,7 @@ export class CodexSessions {
537
548
  if (thread.reply) this.#emit({ type: "session.text.ended", data: { sessionID, assistantMessageID: plainID(thread.reply.id), ordinal: 0, text: cut(thread.reply.text, HARNESS_TEXT_MAX) } })
538
549
  const data = { sessionID, turnID: plainID(turnID) }
539
550
  if (status === "interrupted") this.#emit({ type: "session.execution.interrupted", data })
540
- else if (status === "failed") this.#emit({ type: "session.execution.failed", data: { ...data, ...(error !== undefined ? { error: { message: error } } : {}) } })
551
+ else if (status === "failed") this.#emit({ type: "session.execution.failed", data: { ...data, ...(error !== undefined ? { error: { message: error, ...(end.errorType ? { type: end.errorType } : {}) } } : {}) } })
541
552
  else this.#emit({ type: "session.execution.succeeded", data })
542
553
  thread.items.clear()
543
554
  const waiter = thread.waiters.get(turnID)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-node",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "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).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "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).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",