@huynle/hub 0.11.0 → 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 (102) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +19 -5
  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 +240 -83
  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/mcp.ts +20 -4
  12. package/packages/cli/src/team-intake-mcp.ts +2 -1
  13. package/packages/cli/src/teams-mcp.ts +7 -3
  14. package/packages/codex/package.json +1 -1
  15. package/packages/codex/src/sessions.ts +14 -3
  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/schema/api/v1/_root.json +119 -0
  20. package/packages/protocol/schema/api/v1/account.json +419 -0
  21. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  22. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  23. package/packages/protocol/schema/api/v1/events.json +679 -0
  24. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  25. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  26. package/packages/protocol/schema/api/v1/speech.json +492 -0
  27. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  28. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  29. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  30. package/packages/protocol/src/api-v1.ts +8 -0
  31. package/packages/protocol/src/index.ts +2 -0
  32. package/packages/protocol/src/teams.ts +13 -1
  33. package/packages/runtime-openshell/package.json +1 -1
  34. package/packages/runtime-sdk/package.json +1 -1
  35. package/packages/sdk/package.json +1 -1
  36. package/packages/sdk/src/app-front.ts +2 -2
  37. package/packages/sdk/src/ask-fields.ts +76 -0
  38. package/packages/sdk/src/client.ts +11 -2
  39. package/packages/sdk/src/envelope.ts +3 -2
  40. package/packages/sdk/src/index.ts +1 -0
  41. package/packages/sdk/src/team-intake.ts +6 -0
  42. package/packages/sdk/src/teams.ts +2 -0
  43. package/packages/sdk/src/types.ts +36 -4
  44. package/packages/sdk/src/wake.ts +9 -4
  45. package/packages/spoke-sdk/package.json +1 -1
  46. package/src/central/agent/agent-limits.ts +185 -0
  47. package/src/central/agent/app-decisions.ts +6 -5
  48. package/src/central/agent/decisions.ts +130 -9
  49. package/src/central/agent/delivery.ts +134 -22
  50. package/src/central/agent/errors.ts +2 -1
  51. package/src/central/agent/events.ts +20 -2
  52. package/src/central/agent/launch-engine.ts +6 -3
  53. package/src/central/agent/message-limits.ts +5 -2
  54. package/src/central/agent/message-routes.ts +33 -6
  55. package/src/central/agent/messages.ts +7 -0
  56. package/src/central/agent/placement.ts +4 -2
  57. package/src/central/agent/read-routes.ts +5 -0
  58. package/src/central/agent/routes.ts +2 -0
  59. package/src/central/agent/subscriptions.ts +13 -4
  60. package/src/central/api-v1.ts +115 -0
  61. package/src/central/artifacts/store.ts +4 -2
  62. package/src/central/concierge/board-results.ts +2 -1
  63. package/src/central/concierge/notifier.ts +4 -2
  64. package/src/central/config.ts +20 -6
  65. package/src/central/health/classify.ts +209 -0
  66. package/src/central/http.ts +285 -23
  67. package/src/central/recording/index.ts +2 -1
  68. package/src/central/recording/schema.ts +2 -1
  69. package/src/central/registry.ts +34 -3
  70. package/src/central/teams/budgets.ts +144 -0
  71. package/src/central/teams/hop-stalls.ts +14 -6
  72. package/src/central/teams/intake.ts +38 -6
  73. package/src/central/teams/involvement.ts +95 -0
  74. package/src/central/teams/item-decisions.ts +60 -0
  75. package/src/central/teams/item-eligibility.ts +35 -0
  76. package/src/central/teams/item-stalls.ts +87 -0
  77. package/src/central/teams/loops.ts +40 -0
  78. package/src/central/teams/progress.ts +154 -0
  79. package/src/central/teams/routes.ts +2 -2
  80. package/src/central/teams/store.ts +21 -5
  81. package/src/central/teams/validation.ts +17 -6
  82. package/src/central/workspaces/coordinator.ts +3 -1
  83. package/src/spoke/hub-tool.ts +26 -11
  84. package/web/dist/assets/{ArtifactPage-C5MMqXLN.js → ArtifactPage-TbFJi59n.js} +1 -1
  85. package/web/dist/assets/{ArtifactPane-CXSCmhyJ.js → ArtifactPane-7D-M-Ich.js} +1 -1
  86. package/web/dist/assets/{ArtifactWorkbench-S0RxNGWy.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  87. package/web/dist/assets/{Diagram-BGmyRb-R.js → Diagram-PvOPetOI.js} +1 -1
  88. package/web/dist/assets/{Library-CFmMbXEa.js → Library-Cdi83pOV.js} +2 -2
  89. package/web/dist/assets/{ProfilesDestination-7jpOc2HM.js → ProfilesDestination-cryC7P6j.js} +1 -1
  90. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  91. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  92. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  93. package/web/dist/assets/live-B2IfKm37.js +5 -0
  94. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  95. package/web/dist/assets/{renderer2d-B2Nh39UN.js → renderer2d-CsZnF3_o.js} +1 -1
  96. package/web/dist/assets/{renderer3d-CTYbljHH.js → renderer3d-BIMA0wFn.js} +2 -2
  97. package/web/dist/assets/{safe-area-CkhEclGo.js → safe-area-dtrnw9NF.js} +1 -1
  98. package/web/dist/index.html +1 -1
  99. package/web/dist/assets/TeamsPage-C1NGrUR-.js +0 -1
  100. package/web/dist/assets/Watchtower-DnvlJaU0.js +0 -2
  101. package/web/dist/assets/index-Da8SKXMw.js +0 -33
  102. package/web/dist/assets/live-BmqLASSo.js +0 -5
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.11.0",
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) {
@@ -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"
@@ -126,7 +126,8 @@ const sendOptions = (args: Args) => {
126
126
  const wake = optionalBool(args, "wake")
127
127
  const urgent = optionalBool(args, "urgent")
128
128
  const notAfter = optionalNotAfter(args)
129
- 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 } : {}) }
130
131
  }
131
132
 
132
133
  /** The why argument as SDK act options. */
@@ -238,6 +239,7 @@ const DECISIONS_NOTE = "Decisions are asked by sessions: the question and option
238
239
 
239
240
  const STRING = { type: "string" } as const
240
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)." },
241
243
  wake: { type: "boolean", description: "false: admit the message without starting a turn." },
242
244
  urgent: { type: "boolean", description: "Steer into the recipient's running turn." },
243
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)." },
@@ -767,6 +769,13 @@ const TOOLS: readonly Tool[] = [
767
769
  ...operationTools,
768
770
  ...teamTools,
769
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
+ },
770
779
  {
771
780
  name: "whoami",
772
781
  description: "Who this hub credential is: the app token of `hub login` (or the session of HUB_SESSION_TOKEN) and its user.",
@@ -846,10 +855,11 @@ const TOOLS: readonly Tool[] = [
846
855
  },
847
856
  {
848
857
  name: "ask",
849
- 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.`,
850
859
  properties: {
851
860
  to: TO,
852
861
  text: { ...STRING, description: "The question." },
862
+ ...DECISION_ASK_PROPERTIES,
853
863
  wait: { type: "number", minimum: 0, maximum: WAIT_POLL_MAX_S, description: `Seconds to wait for the answer (0–${WAIT_POLL_MAX_S}).` },
854
864
  expires: { type: ["number", "string"], description: "When the ask expires: seconds, or 30s, 5m, 2h, 1d (1 minute to 7 days; default 24 h)." },
855
865
  ...SEND,
@@ -862,7 +872,13 @@ const TOOLS: readonly Tool[] = [
862
872
  const text = need(args, "text")
863
873
  const wait = optionalWait(args)
864
874
  const expires = optionalExpires(args)
865
- 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 }
866
882
  if (optionalBool(args, "all")) {
867
883
  if (wait !== undefined) throw new ArgError(`"wait" cannot be combined with "all"`)
868
884
  return sentAllText(await hub.ask(to, text, { ...options, all: true }))
@@ -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,7 +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: 100, description: "Agent-only wake hop limit between this team's seats; may not exceed the hub-wide agents.hopLimit. Unset uses the global limit." },
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." },
55
57
  roles: { type: "array", items: ROLE, description: "1–32 roles." },
56
58
  entryRole: { ...STRING, description: "The role that receives submitted work (e.g. pm) and supervises members." },
57
59
  edges: { type: "array", items: EDGE, description: "Communication paths between roles (restricted mode)." },
@@ -178,6 +180,8 @@ function specLines(spec: TeamSpec): string[] {
178
180
  return [
179
181
  `mode: ${spec.mode}${spec.mode === "open" ? " (every member may msg/ask every other)" : " (only the paths below)"}`,
180
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`] : []),
181
185
  `entry role: ${spec.entryRole}`,
182
186
  "roles:",
183
187
  ...spec.roles.map(role),
@@ -349,7 +353,7 @@ export const teamTools: readonly TeamTool[] = [
349
353
  need(args, "name")
350
354
  need(args, "title")
351
355
  const key = optional(args, "idempotencyKey")
352
- const spec = picked(args, ["name", "title", "description", "mode", "hopLimit", "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
353
357
  const { team, created } = await hub.teams.create({ ...spec, edges: spec.edges ?? [] }, { signal, ...(key ? { idempotencyKey: key } : {}) })
354
358
  return [`${created ? "created" : "already created (same key)"}: ${teamLine(team)}`, ...specLines(team)].join("\n")
355
359
  },
@@ -364,7 +368,7 @@ export const teamTools: readonly TeamTool[] = [
364
368
  idempotent: true,
365
369
  run: async (hub, args, signal) => {
366
370
  const expectedRevision = revision(args)
367
- const patch = picked(args, ["title", "description", "mode", "hopLimit", "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
368
372
  if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
369
373
  const id = need(args, "team")
370
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.11.0",
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.11.0",
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.11.0",
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.11.0",
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",
@@ -0,0 +1,119 @@
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "Hub API",
5
+ "version": "1",
6
+ "description": "The versioned HTTP API of a Hub: every route of the console and agent tables under /api/v1. v1 changes only by addition: clients ignore fields they do not know, treat a value outside an `x-hub-known` list as unknown, and skip a variant of an `x-hub-union` they do not know. Every error is one envelope: {error: {code, message, hint?}}."
7
+ },
8
+ "servers": [
9
+ {
10
+ "url": "https://hub.example",
11
+ "description": "A placeholder: the address of your hub."
12
+ }
13
+ ],
14
+ "security": [
15
+ {
16
+ "deviceToken": []
17
+ }
18
+ ],
19
+ "components": {
20
+ "securitySchemes": {
21
+ "deviceToken": {
22
+ "type": "http",
23
+ "scheme": "bearer",
24
+ "description": "A device token: what a native app gets when it redeems a link code (POST /api/v1/devices/redeem). A request with a bearer token needs no Origin."
25
+ },
26
+ "deviceCookie": {
27
+ "type": "apiKey",
28
+ "in": "cookie",
29
+ "name": "voice_device",
30
+ "description": "The browser's credential: the same secret as the device token, as an HttpOnly cookie set when a browser pairs or signs in. A write with the cookie must be same-origin. Send the cookie or a bearer token, never both. Declared here for readers and never listed as a requirement: a generated client sends the bearer token only."
31
+ }
32
+ },
33
+ "responses": {
34
+ "Error": {
35
+ "description": "Any error (status 400 and up): the one envelope of /api/v1.",
36
+ "content": {
37
+ "application/json": {
38
+ "schema": {
39
+ "$ref": "#/components/schemas/ErrorBody"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ },
45
+ "schemas": {
46
+ "ErrorBody": {
47
+ "type": "object",
48
+ "required": ["error"],
49
+ "properties": {
50
+ "error": {
51
+ "$ref": "#/components/schemas/ErrorDetail"
52
+ }
53
+ }
54
+ },
55
+ "ErrorDetail": {
56
+ "type": "object",
57
+ "description": "What went wrong. A code may come with more fields (for example retryAfter, currentRevision, candidates, feature, limit).",
58
+ "required": ["code", "message"],
59
+ "properties": {
60
+ "code": {
61
+ "type": "string",
62
+ "description": "Stable and machine-readable. A route that names no code of its own gets one from the status (400 invalid, 401 unauthorized, 403 forbidden, 404 not_found, 408 and 504 timeout, 409 conflict, 410 gone, 413 too_large, 422 unsupported, 429 rate_limited, 500 internal, 502 upstream_failed, 503 unavailable).",
63
+ "x-hub-known": [
64
+ "ambiguous",
65
+ "bad_answer",
66
+ "bad_bundle",
67
+ "busy",
68
+ "changed",
69
+ "conflict",
70
+ "developer_off",
71
+ "dimensions",
72
+ "expired",
73
+ "forbidden",
74
+ "gone",
75
+ "hidden",
76
+ "integrity",
77
+ "internal",
78
+ "invalid",
79
+ "invalid_cursor",
80
+ "limit",
81
+ "name_taken",
82
+ "needs_user",
83
+ "not_exposed",
84
+ "not_found",
85
+ "offline",
86
+ "path",
87
+ "paused",
88
+ "plugin_outdated",
89
+ "rate_limited",
90
+ "recording_ended",
91
+ "recording_off",
92
+ "refused",
93
+ "timeout",
94
+ "token_expired",
95
+ "too_large",
96
+ "too_old",
97
+ "type",
98
+ "unauthorized",
99
+ "unavailable",
100
+ "unsupported",
101
+ "upstream_failed"
102
+ ]
103
+ },
104
+ "message": {
105
+ "type": "string",
106
+ "description": "For people."
107
+ },
108
+ "hint": {
109
+ "type": "string",
110
+ "description": "What to do about it, when the hub knows."
111
+ }
112
+ }
113
+ },
114
+ "JsonValue": {
115
+ "description": "Any JSON value: an object, an array, a string, a number, a boolean or null. For data whose shape the hub does not define (a tool's input, a harness's own metadata). Clients map it to their JSON element type."
116
+ }
117
+ }
118
+ }
119
+ }