@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
@@ -79,6 +79,23 @@ export const DECISION_TICK_MS = 30_000
79
79
 
80
80
  const OPTION_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/
81
81
 
82
+ /** (DC-1) Choice groups of a Decision, options per group, and characters of a group's label. */
83
+ export const CHOICES_MAX = 12
84
+ export const CHOICE_OPTIONS_MIN = 2
85
+ export const CHOICE_OPTIONS_MAX = 6
86
+ export const CHOICE_LABEL_MAX = 120
87
+ const CHOICE_ID = /^[A-Za-z0-9_-]{1,8}$/
88
+ /** (DC-1) What an ask is about, for the card's header (absent: a question). */
89
+ export type DecisionKind = "question" | "plan" | "merge" | "release"
90
+ export const DECISION_KINDS: readonly DecisionKind[] = ["question", "plan", "merge", "release"]
91
+
92
+ /** (DC-1) One point of a structured ask: the user picks one of its options. */
93
+ export interface DecisionChoice {
94
+ readonly id: string
95
+ readonly label: string
96
+ readonly options: readonly { readonly id: string; readonly label: string; readonly recommended?: true }[]
97
+ }
98
+
82
99
  export interface DecisionOption {
83
100
  readonly id: string
84
101
  readonly label: string
@@ -88,6 +105,8 @@ export interface DecisionOption {
88
105
 
89
106
  export interface DecisionAnswer {
90
107
  readonly option?: string
108
+ /** (DC-1) A choices Decision: the option picked in each group (absent: answered with text only). */
109
+ readonly picks?: Readonly<Record<string, string>>
91
110
  readonly text?: string
92
111
  /** (AF4) "app": an app holding decisions:answer answered a confirm-tier Decision of its own session. */
93
112
  readonly answeredBy: "user" | "timeout" | "app"
@@ -124,7 +143,22 @@ export function appAnswerRefusal(decision: Pick<Decision, "tier" | "action" | "a
124
143
  }
125
144
 
126
145
  /** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
127
- export type DecisionAction = PermissionAction | GrantRequestAction | ActAction
146
+ export type DecisionAction = PermissionAction | GrantRequestAction | ActAction | TeamHopRenewAction | TeamItemAction
147
+
148
+ /** Hub-created, tap-only action: an agent cannot authorize a team's hop budget renewal. */
149
+ export interface TeamHopRenewAction {
150
+ readonly kind: "teamHopRenew"
151
+ readonly teamID: string
152
+ }
153
+
154
+ /** (TA2-7) Hub-created, tap-only: Extend or Stop one intake item that spent its budget or loops without progress. */
155
+ export interface TeamItemAction {
156
+ readonly kind: "teamItem"
157
+ readonly teamID: string
158
+ readonly item: string
159
+ readonly reason: "budget" | "loop"
160
+ readonly pair?: readonly [string, string]
161
+ }
128
162
 
129
163
  /**
130
164
  * (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER
@@ -227,6 +261,9 @@ export interface Decision {
227
261
  readonly question: string
228
262
  readonly options: readonly DecisionOption[]
229
263
  readonly recommendation?: { readonly option: string; readonly why?: string }
264
+ /** (DC-1) Structured choices (an agent's ask only; never with options or an action). */
265
+ readonly choices?: readonly DecisionChoice[]
266
+ readonly kind?: DecisionKind
230
267
  readonly urgency: Urgency
231
268
  readonly tier: Tier
232
269
  readonly deadline?: number
@@ -257,6 +294,8 @@ export interface AskInput {
257
294
  readonly question: string
258
295
  readonly options?: readonly { readonly id: string; readonly label: string; readonly safe?: boolean }[]
259
296
  readonly recommendation?: { readonly option: string; readonly why?: string }
297
+ readonly choices?: readonly { readonly id: string; readonly label: string; readonly options: readonly { readonly id: string; readonly label: string; readonly recommended?: boolean }[] }[]
298
+ readonly kind?: DecisionKind
260
299
  readonly urgency?: Urgency
261
300
  readonly tier?: Tier
262
301
  /** From now. */
@@ -271,6 +310,8 @@ export interface AskInput {
271
310
  export interface AnswerInput {
272
311
  readonly option?: string
273
312
  readonly text?: string
313
+ /** (DC-1) One option per choice group. */
314
+ readonly picks?: Readonly<Record<string, string>>
274
315
  /** (CON-06) Absent = tap. */
275
316
  readonly via?: AnswerVia
276
317
  }
@@ -305,6 +346,8 @@ export interface DecisionsDeps {
305
346
  readonly changed?: (userID: string, decision: Decision) => void
306
347
  /** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
307
348
  readonly act?: DecisionActor
349
+ /** Only the authenticated user's answer path; never answerByApp or the deadline sweep. */
350
+ readonly answered?: (userID: string, decision: Decision, by: UserActor) => Promise<void>
308
351
  /** (CON-06) Admits voice/text answers; without it every voice/text answer is refused. */
309
352
  readonly via?: ViaCheck
310
353
  /** (CON-06) Whether a session is one of the user's Concierge sessions (current or past): the Decision's `as`. */
@@ -364,6 +407,9 @@ function validate(input: AskInput) {
364
407
  if (typeof option !== "string" || !has(option)) throw invalid("The recommendation must name one of the options")
365
408
  recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
366
409
  }
410
+ const choices = input.choices === undefined ? undefined : validChoices(input.choices)
411
+ if (choices && options.length) throw invalid("An ask offers either options or choices, not both")
412
+ if (input.kind !== undefined && !DECISION_KINDS.includes(input.kind)) throw invalid(`kind is one of ${DECISION_KINDS.join(", ")}`)
367
413
  const urgency = input.urgency ?? "normal"
368
414
  if (!URGENCIES.includes(urgency)) throw invalid(`urgency is one of ${URGENCIES.join(", ")}`)
369
415
  const tier = input.tier ?? "confirm"
@@ -378,7 +424,62 @@ function validate(input: AskInput) {
378
424
  }
379
425
  const expiresInMs = input.expiresInMs ?? Math.min(ASK_EXPIRES_MAX_MS, Math.max(ASK_EXPIRES_MS, (deadlineMs ?? 0) + ASK_EXPIRES_MS))
380
426
  if (deadlineMs !== undefined && deadlineMs > expiresInMs) throw invalid("The deadline is after the ask expires")
381
- return { options, recommendation, urgency, tier, deadlineMs, onTimeout, expiresInMs }
427
+ return { options, recommendation, choices, kind: input.kind, urgency, tier, deadlineMs, onTimeout, expiresInMs }
428
+ }
429
+
430
+ function validChoices(raw: unknown): DecisionChoice[] {
431
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > CHOICES_MAX) throw invalid(`choices are 1–${CHOICES_MAX} groups`)
432
+ const groups: DecisionChoice[] = []
433
+ for (const group of raw as Record<string, unknown>[]) {
434
+ if (typeof group?.id !== "string" || !CHOICE_ID.test(group.id)) throw invalid("Choice group ids are 1–8 letters, digits, - or _")
435
+ if (groups.some((g) => g.id === group.id)) throw invalid(`Choice group ${group.id} is given twice`)
436
+ const label = cleanLabel(group.label, CHOICE_LABEL_MAX, `Choice group ${group.id}'s label`)
437
+ const list = group.options
438
+ if (!Array.isArray(list) || list.length < CHOICE_OPTIONS_MIN || list.length > CHOICE_OPTIONS_MAX) throw invalid(`Choice group ${group.id} offers ${CHOICE_OPTIONS_MIN}–${CHOICE_OPTIONS_MAX} options`)
439
+ const options: { id: string; label: string; recommended?: true }[] = []
440
+ for (const option of list as Record<string, unknown>[]) {
441
+ if (typeof option?.id !== "string" || !OPTION_ID.test(option.id)) throw invalid("Option ids are 1–32 lowercase letters, digits, - or _")
442
+ if (options.some((o) => o.id === option.id)) throw invalid(`Option ${option.id} is given twice in ${group.id}`)
443
+ if (option.recommended !== undefined && typeof option.recommended !== "boolean") throw invalid("recommended must be true or false")
444
+ options.push({ id: option.id, label: cleanLabel(option.label, OPTION_LABEL_MAX, "An option label"), ...(option.recommended === true ? { recommended: true as const } : {}) })
445
+ }
446
+ if (options.filter((o) => o.recommended).length > 1) throw invalid(`Choice group ${group.id} has more than one recommended option`)
447
+ groups.push({ id: group.id, label, options })
448
+ }
449
+ return groups
450
+ }
451
+
452
+ /** (DC-1) An answer body's `picks`: undefined when absent, else {group: option} strings (checked against the Decision later). */
453
+ export function answerPicks(raw: unknown): Record<string, string> | undefined {
454
+ if (raw === undefined) return undefined
455
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw) || Object.keys(raw).length > CHOICES_MAX) throw invalid("picks must be {group: option}")
456
+ // No prototype: a "__proto__" key stays an own key (an unknown group, refused later) instead of vanishing.
457
+ const out: Record<string, string> = Object.create(null)
458
+ for (const [group, option] of Object.entries(raw)) {
459
+ if (typeof option !== "string") throw invalid("picks must be {group: option}")
460
+ out[group] = option
461
+ }
462
+ return out
463
+ }
464
+
465
+ /** The audit detail of an answer: the option, the picks (validated IDs) or "text". */
466
+ export function answerDetail(answer: DecisionAnswer | undefined): string {
467
+ if (answer?.option !== undefined) return answer.option
468
+ if (answer?.picks) return `picks ${Object.entries(answer.picks).map(([g, o]) => `${g}=${o}`).join(",")}`
469
+ return "text"
470
+ }
471
+
472
+ /** What the asker reads: the option, the picks ("D1: label [a]" per line) or the text, then the note. */
473
+ function answerText(decision: Decision, choice: { option?: string; text?: string; picks?: Readonly<Record<string, string>> }, by?: string): string {
474
+ const head = `Decision ${decision.id}:${by ? ` ${by}` : ""}`
475
+ if (choice.picks && decision.choices) {
476
+ const lines = decision.choices.map((g) => `${g.id}: ${g.options.find((o) => o.id === choice.picks![g.id])?.label ?? choice.picks![g.id]} [${choice.picks![g.id]}]`)
477
+ return `${head}\n${lines.join("\n")}${choice.text ? `\n${choice.text}` : ""}`
478
+ }
479
+ const option = decision.options.find((o) => o.id === choice.option)
480
+ if (option) return `Decision ${decision.id}: ${option.label} [${option.id}]${by ? ` ${by}` : ""}${choice.text ? `\n${choice.text}` : ""}`
481
+ if (decision.choices) return `Decision ${decision.id}: no picks, text: ${choice.text}${by ? ` ${by}` : ""}`
482
+ return `Decision ${decision.id}: ${choice.text}${by ? ` ${by}` : ""}`
382
483
  }
383
484
 
384
485
  export class Decisions {
@@ -404,6 +505,7 @@ export class Decisions {
404
505
  if (actor.kind === "user") throw invalid("You are @me: the user cannot ask themselves")
405
506
  if (typeof input.question !== "string") throw invalid("question must be a string")
406
507
  const v = validate(input)
508
+ if (v.choices && extra.action) throw invalid("A hub action never uses choices")
407
509
  const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
408
510
  const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
409
511
  const asConcierge = actor.kind === "session" && (await this.isConcierge(userID, actor.sessionID))
@@ -416,6 +518,8 @@ export class Decisions {
416
518
  question: message.text,
417
519
  options: v.options,
418
520
  ...(v.recommendation ? { recommendation: v.recommendation } : {}),
521
+ ...(v.choices ? { choices: v.choices } : {}),
522
+ ...(v.kind ? { kind: v.kind } : {}),
419
523
  urgency: v.urgency,
420
524
  tier: v.tier,
421
525
  ...(v.deadlineMs === undefined ? {} : { deadline: message.at + v.deadlineMs }),
@@ -480,9 +584,13 @@ export class Decisions {
480
584
  const decision = await this.settle(userID, id, answer)
481
585
  if (!decision) throw gone()
482
586
  const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
587
+ // The answer is settled and any action has completed. An involvement hook must not undo the user's answer.
588
+ if (this.deps.answered) {
589
+ try { await this.deps.answered(userID, decision, by) }
590
+ catch (cause) { console.error("[hub] user Decision involvement failed:", cause) }
591
+ }
483
592
  this.emit(userID, decision)
484
- const option = decision.options.find((o) => o.id === choice.option)
485
- const said = option ? `Decision ${id}: ${option.label} [${option.id}]${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text}`
593
+ const said = answerText(decision, choice)
486
594
  const text = note ? `${said}\n${note}` : said
487
595
  return { decision, ...(await this.replyTo(userID, decision, text, by)) }
488
596
  }
@@ -496,7 +604,7 @@ export class Decisions {
496
604
  async answerByApp(
497
605
  userID: string,
498
606
  id: string,
499
- input: { readonly option?: string; readonly text?: string },
607
+ input: { readonly option?: string; readonly text?: string; readonly picks?: Readonly<Record<string, string>> },
500
608
  app: AppAnswerer,
501
609
  checks: { readonly owned: (decision: Decision) => Promise<boolean>; readonly ready?: (decision: Decision) => void },
502
610
  ): Promise<{ decision: Decision; reply?: Message }> {
@@ -513,9 +621,7 @@ export class Decisions {
513
621
  const decision = await this.settle(userID, id, answer)
514
622
  if (!decision) throw gone()
515
623
  this.emit(userID, decision)
516
- const option = decision.options.find((o) => o.id === choice.option)
517
- const by = `(answered by the app “${label ?? app.party}”)`
518
- const text = option ? `Decision ${id}: ${option.label} [${option.id}] ${by}${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text} ${by}`
624
+ const text = answerText(decision, choice, `(answered by the app “${label ?? app.party}”)`)
519
625
  return { decision, ...(await this.replyTo(userID, decision, text, HUB_USER(userID))) }
520
626
  }
521
627
 
@@ -610,8 +716,29 @@ export class Decisions {
610
716
  return decision.state === "open" && this.now() >= decision.expiresAt ? { ...decision, state: "expired" } : decision
611
717
  }
612
718
 
613
- private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string } {
719
+ private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string; picks?: Readonly<Record<string, string>> } {
614
720
  const text = input.text === undefined ? undefined : cleanText(input.text, ANSWER_MAX, "The answer text")
721
+ if (input.picks !== undefined && !decision.choices) throw invalid("This decision has no choices")
722
+ if (decision.choices) {
723
+ if (input.option !== undefined) throw invalid("This decision has choices: answer with picks")
724
+ // Text alone is the fallback of a console or device that knows no picks.
725
+ if (input.picks === undefined) {
726
+ if (text === undefined) throw invalid(`Pick an option for each of ${decision.choices.map((g) => g.id).join(", ")}`)
727
+ return { text }
728
+ }
729
+ const picks = input.picks as unknown
730
+ if (typeof picks !== "object" || picks === null || Array.isArray(picks)) throw invalid("picks must be {group: option}")
731
+ const given = picks as Record<string, unknown>
732
+ for (const key of Object.keys(given)) if (!decision.choices.some((g) => g.id === key)) throw invalid(`No choice group ${sanitizeLabel(key, 16)}`)
733
+ const out: Record<string, string> = {}
734
+ for (const group of decision.choices) {
735
+ const pick = given[group.id]
736
+ if (pick === undefined) throw invalid(`Pick an option for ${group.id}`)
737
+ if (typeof pick !== "string" || !group.options.some((o) => o.id === pick)) throw invalid(`Pick one of ${group.id}'s options: ${group.options.map((o) => o.id).join(", ")}`)
738
+ out[group.id] = pick
739
+ }
740
+ return { picks: out, ...(text === undefined ? {} : { text }) }
741
+ }
615
742
  if (!decision.options.length) {
616
743
  if (input.option !== undefined) throw invalid("This decision has no options: answer with text")
617
744
  if (text === undefined) throw invalid("This decision needs an answer text")
@@ -48,6 +48,7 @@
48
48
  // answers, and nothing sent to an app raises a persistent depth.
49
49
  import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
50
50
  import type { WakeSkip } from "../../../packages/sdk/src/types.ts"
51
+ import type { ItemBudgets } from "../teams/budgets"
51
52
  import { isDeliverResult } from "../../protocol"
52
53
  import { randomBytes } from "node:crypto"
53
54
  import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
@@ -58,7 +59,7 @@ import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, rende
58
59
  import { noticeOnly } from "../../../packages/sdk/src/envelope.ts"
59
60
  import { AgentError } from "./errors"
60
61
  import { type ExpiredReason, parseNotAfter } from "./not-after"
61
- import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
62
+ import { HOP_ABSOLUTE_MAX, HOP_CAP, HopTracker, MessageLimiter, type MessageLimits } from "./message-limits"
62
63
  import { parseAddress } from "./names"
63
64
  import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
64
65
 
@@ -115,6 +116,10 @@ export interface InboundPolicy {
115
116
  }
116
117
 
117
118
  export interface DeliveryDeps {
119
+ /** Item budgets are consulted only for shared-team exchanges, after the existing wake backstop admits. */
120
+ readonly itemBudgets?: Pick<ItemBudgets, "wake" | "current" | "currents" | "setCurrent" | "chargeChild">
121
+ /** Current team/item eligibility, shared by explicit sends and inherited replies. */
122
+ readonly items?: { eligible(userID: string, item: string, sessionIDs: readonly string[]): Promise<{ readonly ok: true; readonly teamID: string; readonly startedAt?: number; readonly parentItem?: string; readonly child?: boolean } | { readonly ok: false; readonly reason: "closed" | "archived" | "unseated"; readonly sessionID?: string; readonly team?: string }> }
118
123
  readonly state: StateStore
119
124
  readonly counters: Counters
120
125
  readonly registry: DeliveryRegistry
@@ -126,10 +131,23 @@ export interface DeliveryDeps {
126
131
  /** Labels of a session sender (default: its @name from the directory). */
127
132
  readonly describe?: (userID: string, sessionID: string) => PartyInfo
128
133
  readonly alert?: (userID: string, alert: DeliveryAlert) => void
134
+ /** The first over-cap message between two seats of a team, once per persistent chain. */
135
+ readonly teamHopCap?: (userID: string, input: { readonly teamID: string; readonly sender: string; readonly recipient: string; readonly limit: number }) => Promise<void>
136
+ /**
137
+ * (TA2-7) A message tagged with an item between two seats: true when that pair is looping on the item without
138
+ * progress, so the message is delivered without a wake (and charges no wake). Never throws through: a failure wakes.
139
+ */
140
+ readonly itemLoop?: (userID: string, input: { readonly teamID: string; readonly item: string; readonly from: string; readonly to: string }) => Promise<boolean>
141
+ /** A quiet, hub-authored note to the team's entry seat; never counts a message hop. */
142
+ readonly teamHopNotice?: (userID: string, teamID: string) => Promise<string | undefined>
129
143
  /** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
130
144
  readonly expired?: (userID: string, message: Message) => void
131
145
  /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
132
146
  readonly limits?: Partial<MessageLimits>
147
+ /** Configured global agent-only wake hop limit (default 8; the live ceiling is AgentLimits', the bound HOP_ABSOLUTE_MAX). */
148
+ readonly hopLimit?: number
149
+ /** Latest hub-wide limit, consulted for every send and wake (console settings can change it live). */
150
+ readonly currentHopLimit?: () => number
133
151
  /** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
134
152
  readonly relayWindowMs?: number
135
153
  /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
@@ -141,12 +159,14 @@ export interface DeliveryDeps {
141
159
  /** How often the re-wake loop runs (default REWAKE_TICK_MS). */
142
160
  readonly tickMs?: number
143
161
  /** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
144
- readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
162
+ readonly teams?: Pick<TeamPolicy, "prepare" | "answer" | "hopContext">
145
163
  /** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
146
164
  readonly inbound?: InboundPolicy
147
165
  }
148
166
 
149
167
  export interface SendOptions {
168
+ /** Explicit team intake item, already authorized by the agent message route. */
169
+ readonly item?: string
150
170
  /** false: admit without starting a turn (reported queued). */
151
171
  readonly wake?: boolean
152
172
  /** Steer into a running turn, like an ask. */
@@ -173,6 +193,8 @@ export interface Sent {
173
193
  readonly woke?: boolean
174
194
  /** Why it was not (only when woke is false). */
175
195
  readonly wakeSkipped?: WakeSkip
196
+ /** Effective hop cap or wake budget, only with the corresponding wakeSkipped reason. */
197
+ readonly wakeLimit?: number
176
198
  }
177
199
 
178
200
  /** A reply or decline as sent, with the message it answered. */
@@ -273,6 +295,7 @@ export class Delivery {
273
295
  private readonly owner: string
274
296
 
275
297
  constructor(deps: DeliveryDeps) {
298
+ if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit > HOP_ABSOLUTE_MAX)) throw new Error(`hopLimit must be 1–${HOP_ABSOLUTE_MAX}`)
276
299
  this.deps = deps
277
300
  const now = (this.now = deps.now ?? Date.now)
278
301
  this.timers = deps.timers ?? defaultTimers
@@ -314,7 +337,9 @@ export class Delivery {
314
337
  if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
315
338
  const from = this.party(userID, actor)
316
339
  const resolved = await this.resolveFor(userID, actor, from, addr, kind)
317
- return this.sendTo(userID, from, addr, resolved, this.pick(resolved), kind, text, options)
340
+ const picked = this.pick(resolved)
341
+ const itemContext = await this.checkItem(userID, options.item, from, addr, [picked.sessionID])
342
+ return this.sendTo(userID, from, addr, resolved, picked, kind, text, options, itemContext)
318
343
  }
319
344
 
320
345
  /** Like send, to every session the address names (each role holder gets its own message). */
@@ -323,11 +348,22 @@ export class Delivery {
323
348
  if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
324
349
  const from = this.party(userID, actor)
325
350
  const resolved = await this.resolveFor(userID, actor, from, addr, kind)
351
+ const itemContext = await this.checkItem(userID, options.item, from, addr, resolved.sessions.map((s) => s.sessionID))
326
352
  const sent: Sent[] = []
327
- for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
353
+ for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options, itemContext))
328
354
  return sent
329
355
  }
330
356
 
357
+ private async checkItem(userID: string, item: string | undefined, from: Party, addr: string, recipients: readonly string[]): Promise<{ teamID: string; startedAt?: number; parentItem?: string; child?: boolean } | undefined> {
358
+ if (item === undefined) return undefined
359
+ if (from.kind !== "session" || !this.deps.items) throw new AgentError("invalid", "item applies to messages between team members")
360
+ const result = await this.deps.items.eligible(userID, item, [from.sessionID, ...recipients])
361
+ if (result.ok) return { teamID: result.teamID, ...(result.startedAt !== undefined ? { startedAt: result.startedAt } : {}), ...(result.parentItem ? { parentItem: result.parentItem } : {}), ...(result.child ? { child: true } : {}) }
362
+ if (result.reason === "closed") throw new AgentError("invalid", `item ${item} is closed`)
363
+ if (result.reason === "archived") throw new AgentError("invalid", `team ${result.team ?? "of this item"} is archived`)
364
+ throw new AgentError("invalid", `the sender and every recipient of ${addr} must be seated in item ${item}'s team`)
365
+ }
366
+
331
367
  /** A notAfter validated against this engine's clock (parseNotAfter): throws invalid or expired. */
332
368
  checkNotAfter(value: unknown): number | undefined {
333
369
  return parseNotAfter(value, this.now())
@@ -348,6 +384,30 @@ export class Delivery {
348
384
  return this.hops.humanTurn(userID, sessionID)
349
385
  }
350
386
 
387
+ /** A quiet hub notice (never a wake, never a hop) to the team's entry seat; false when the team has none. */
388
+ async notifyTeamEntry(userID: string, teamID: string, text: string): Promise<boolean> {
389
+ const entry = await this.deps.teamHopNotice?.(userID, teamID)
390
+ if (!entry) return false
391
+ const id = this.newID()
392
+ await this.request(userID, entry, {
393
+ op: "deliver", sessionID: entry, messageID: id, wake: false,
394
+ text: `[hub] notice · ${text} · ${id}`,
395
+ meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
396
+ })
397
+ return true
398
+ }
399
+
400
+ async notifyTeamHopCap(userID: string, teamID: string, decisionID: string): Promise<void> {
401
+ const entry = await this.deps.teamHopNotice?.(userID, teamID)
402
+ if (!entry) return
403
+ const id = this.newID()
404
+ await this.request(userID, entry, {
405
+ op: "deliver", sessionID: entry, messageID: id, wake: false,
406
+ text: `[hub] notice · Team messages reached the hop cap. The user has a Renew Decision ${decisionID}; wait for their choice. · ${id}`,
407
+ meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
408
+ })
409
+ }
410
+
351
411
  /** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
352
412
  /** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
353
413
  flush(userID: string, sessionID?: string): Promise<void> {
@@ -466,7 +526,7 @@ export class Delivery {
466
526
  return { message }
467
527
  }
468
528
 
469
- private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
529
+ private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions, itemContext?: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean }): Promise<Sent> {
470
530
  const sessionID = entry.sessionID
471
531
  const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
472
532
  await this.deps.inbound?.outbound?.(userID, from, kind)
@@ -475,6 +535,19 @@ export class Delivery {
475
535
  await this.limiter.admit(userID, from, recipient)
476
536
  const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
477
537
  const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
538
+ // Try the sender's latest received/assigned item across teams, not just the default shared hop team:
539
+ // a session may be seated in A and B while its current work belongs to B.
540
+ if (!options.item && from.kind === "session" && this.deps.itemBudgets && this.deps.items) {
541
+ for (const candidate of await this.deps.itemBudgets.currents(userID, from.sessionID)) {
542
+ try {
543
+ const eligible = await this.deps.items.eligible(userID, candidate.item, [from.sessionID, sessionID])
544
+ if (!eligible.ok || eligible.teamID !== candidate.teamID) continue
545
+ itemContext = { teamID: eligible.teamID, ...(eligible.startedAt !== undefined ? { startedAt: eligible.startedAt } : {}), ...(eligible.parentItem ? { parentItem: eligible.parentItem } : {}), ...(eligible.child ? { child: true } : {}) }
546
+ options = { ...options, item: candidate.item }
547
+ break
548
+ } catch (cause) { console.error("[hub] sender item context lookup failed:", cause) }
549
+ }
550
+ }
478
551
  const message = await this.messages.send(userID, {
479
552
  from,
480
553
  to: addr,
@@ -482,6 +555,7 @@ export class Delivery {
482
555
  kind,
483
556
  text,
484
557
  hop,
558
+ ...(options.item ? { item: options.item } : {}),
485
559
  ...(generation === undefined ? {} : { generation }),
486
560
  ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
487
561
  ...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
@@ -489,12 +563,22 @@ export class Delivery {
489
563
  await this.hops.received(userID, recipient, hop, from)
490
564
  this.users.add(userID)
491
565
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
492
- const skipped = await this.wakeFor(userID, message, unwanted)
493
- if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
566
+ const team = "team" in resolved ? resolved.team : undefined
567
+ const explicitTeamID = typeof team === "object" && team !== null && "teamID" in team && typeof team.teamID === "string" ? team.teamID : undefined
568
+ const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID ?? itemContext?.teamID)
569
+ if (context && itemContext?.teamID === context.teamID && this.deps.itemBudgets && message.item) {
570
+ await this.deps.itemBudgets.setCurrent(userID, context.teamID, sessionID, message.item)
571
+ .catch((cause) => console.error("[hub] item context write failed:", cause))
572
+ }
573
+ const global = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP
574
+ const limit = Math.min(context?.limit ?? global, global)
575
+ const skip = await this.wakeFor(userID, message, unwanted, limit, context?.teamID, itemContext?.teamID === context?.teamID ? itemContext : undefined)
576
+ const skipped = skip.reason
577
+ if (kind === "ask" && unwanted === undefined && hop <= limit) {
494
578
  const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
495
579
  await this.deps.state.put(userID, REWAKE, message.id, rewake)
496
580
  }
497
- return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
581
+ return this.deliver(userID, message, sessionID, skipped, options.urgent === true, skip.wakeLimit ?? (skipped === "hop_cap" ? limit : skipped === "budget" ? this.limiter.wakeLimit : undefined))
498
582
  }
499
583
 
500
584
  private schedule(): void {
@@ -589,7 +673,7 @@ export class Delivery {
589
673
  replyTo: string,
590
674
  options: SendOptions,
591
675
  kind: "reply" | "decline",
592
- store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
676
+ store: (input: { from: Party; replyTo: string; hop: number; generation?: number; item?: string }) => Promise<{ message: Message; original: Message }>,
593
677
  ): Promise<Replied> {
594
678
  const from = this.party(userID, actor)
595
679
  const original = await this.messages.get(userID, replyTo)
@@ -605,36 +689,94 @@ export class Delivery {
605
689
  if (to) await this.limiter.admit(userID, from, to)
606
690
  const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
607
691
  const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
608
- const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
692
+ let item: string | undefined
693
+ let itemContext: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean } | undefined
694
+ if (original?.item && from.kind === "session" && to?.kind === "session") {
695
+ try {
696
+ const eligible = await this.deps.items?.eligible(userID, original.item, [from.sessionID, to.sessionID])
697
+ if (eligible?.ok) { item = original.item; itemContext = { teamID: eligible.teamID, ...(eligible.startedAt !== undefined ? { startedAt: eligible.startedAt } : {}), ...(eligible.parentItem ? { parentItem: eligible.parentItem } : {}), ...(eligible.child ? { child: true } : {}) } }
698
+ } catch (cause) {
699
+ // A reply must still be delivered; uncertainty about item attribution fails closed (no item).
700
+ console.error("[hub] reply item eligibility failed:", cause)
701
+ }
702
+ }
703
+ const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }), ...(item ? { item } : {}) })
609
704
  const { message } = answered
610
705
  await this.hops.received(userID, message.recipient, hop, from)
611
706
  this.users.add(userID)
612
707
  if (message.recipient.kind !== "session") return answered
613
708
  const sessionID = message.recipient.sessionID
614
709
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
615
- const skipped = await this.wakeFor(userID, message, unwanted)
616
- return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
710
+ const context = await this.deps.teams?.hopContext(userID, from, message.recipient, itemContext?.teamID)
711
+ if (context && itemContext?.teamID === context.teamID && this.deps.itemBudgets && message.item) {
712
+ await this.deps.itemBudgets.setCurrent(userID, context.teamID, sessionID, message.item)
713
+ .catch((cause) => console.error("[hub] item context write failed:", cause))
714
+ }
715
+ const global = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP
716
+ const limit = Math.min(context?.limit ?? global, global)
717
+ const skip = await this.wakeFor(userID, message, unwanted, limit, context?.teamID, itemContext?.teamID === context?.teamID ? itemContext : undefined)
718
+ const skipped = skip.reason
719
+ return { ...(await this.deliver(userID, message, sessionID, skipped, true, skip.wakeLimit ?? (skipped === "hop_cap" ? limit : skipped === "budget" ? this.limiter.wakeLimit : undefined))), original: answered.original }
617
720
  }
618
721
 
619
722
  /**
620
723
  * Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
621
724
  * the hop cap (alerting once at the cap), or no wake budget left.
622
725
  */
623
- private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
624
- if (unwanted) return unwanted
625
- if (message.recipient.kind !== "session") return "wake_false"
626
- if (overHopCap(message.hop)) {
627
- if (message.hop === HOP_CAP + 1) {
726
+ private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP, teamID?: string, itemContext?: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean }): Promise<{ reason?: WakeSkip; wakeLimit?: number }> {
727
+ if (unwanted) return { reason: unwanted }
728
+ if (message.recipient.kind !== "session") return { reason: "wake_false" }
729
+ if (message.hop > limit) {
730
+ if (teamID && message.from.kind === "session") {
731
+ try {
732
+ await this.deps.teamHopCap?.(userID, { teamID, sender: message.from.sessionID, recipient: message.recipient.sessionID, limit })
733
+ } catch (cause) {
734
+ // The message has already been stored. A failed Decision must not prevent delivery or wake it.
735
+ console.error(`[hub] team hop-cap alert failed: ${cause instanceof Error ? cause.message : String(cause)}`)
736
+ }
737
+ } else if (message.hop === limit + 1) {
628
738
  this.deps.alert?.(userID, {
629
739
  kind: "hopCap",
630
740
  sessionID: message.recipient.sessionID,
631
741
  messageID: message.id,
632
- text: `Agent messages went ${HOP_CAP} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
742
+ text: `Agent messages went ${limit} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
633
743
  })
634
744
  }
635
- return "hop_cap"
745
+ return { reason: "hop_cap" }
746
+ }
747
+ if (teamID && itemContext?.teamID === teamID && message.item && message.from.kind === "session" && this.deps.itemLoop) {
748
+ let looping = false
749
+ try { looping = await this.deps.itemLoop(userID, { teamID, item: message.item, from: message.from.sessionID, to: message.recipient.sessionID }) }
750
+ catch (cause) { console.error("[hub] item loop check failed:", cause) }
751
+ if (looping) return { reason: "item_loop" }
752
+ }
753
+ if (!(await this.limiter.wake(userID, message.recipient))) return { reason: "budget" }
754
+ if (teamID && this.deps.itemBudgets) {
755
+ // A wake is the hub deciding to wake: do not refund for a later spoke failure or offline pending delivery.
756
+ let chargedItem = itemContext && message.item ? (itemContext.child ? itemContext.parentItem : message.item) : undefined
757
+ let chargedStartedAt = chargedItem === message.item ? itemContext?.startedAt : undefined
758
+ if (chargedItem && itemContext?.parentItem) {
759
+ try {
760
+ const root = message.from.kind === "session" && message.recipient.kind === "session" ? await this.deps.items?.eligible(userID, chargedItem, [message.from.sessionID, message.recipient.sessionID]) : undefined
761
+ if (!root?.ok || root.child || root.teamID !== teamID) chargedItem = undefined
762
+ else chargedStartedAt = root.startedAt
763
+ } catch (cause) { console.error("[hub] parent item eligibility failed:", cause); chargedItem = undefined }
764
+ }
765
+ let budget: Awaited<ReturnType<ItemBudgets["wake"]>>
766
+ const seats = message.from.kind === "session" ? [message.from.sessionID, message.recipient.sessionID] : [message.recipient.sessionID]
767
+ try { budget = await this.deps.itemBudgets.wake(userID, teamID, chargedItem, chargedStartedAt, seats) }
768
+ catch (cause) {
769
+ // The message is stored already. A failed budget read/write must never turn it into a send error or a wake.
770
+ console.error("[hub] item budget charge failed; skipping wake:", cause)
771
+ return { reason: "item_budget" }
772
+ }
773
+ if (budget.allowed && itemContext?.child && message.item) {
774
+ try { await this.deps.itemBudgets.chargeChild(userID, teamID, message.item, chargedItem ?? "pool") }
775
+ catch (cause) { console.error("[hub] child item usage failed:", cause) }
776
+ }
777
+ if (!budget.allowed) return { reason: "item_budget", ...(budget.reason === "wakes" ? { wakeLimit: budget.limit } : {}) }
636
778
  }
637
- return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
779
+ return {}
638
780
  }
639
781
 
640
782
  /** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
@@ -672,9 +814,9 @@ export class Delivery {
672
814
  }
673
815
 
674
816
  /** Delivers now, or queues the message as pending when no spoke takes it. */
675
- private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
817
+ private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean, wakeLimit?: number): Promise<Sent> {
676
818
  const wake = skipped === undefined
677
- const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
819
+ const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped, ...(wakeLimit !== undefined ? { wakeLimit } : {}) }
678
820
  // Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
679
821
  // one starts then carries them, instead of them arriving inside it.
680
822
  if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
@@ -1,5 +1,6 @@
1
1
  // The /api/agent error envelope (design §8, §14.5): {error: {code, message, hint?, ...extra}}. Only /api/agent/*
2
- // answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it).
2
+ // answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it), which
3
+ // is rewritten to this one under /api/v1 only (../api-v1.ts).
3
4
 
4
5
  /** HTTP status of each agent error code. */
5
6
  export const AGENT_ERROR_STATUS = {
@@ -244,9 +244,18 @@ export function roleEvent(sessionID: string, held: boolean, role: { role: string
244
244
  }
245
245
  }
246
246
 
247
+ /**
248
+ * (SS-1) Whether a session.changed is a change of state, not of health alone (state === prev). Before health fields,
249
+ * every session.changed was a state change; consumers that mean "went into state X" check this.
250
+ */
251
+ export const stateChanged = (data: Readonly<Record<string, unknown>> | undefined): boolean => data?.prev === undefined || data.prev !== data.state
252
+
247
253
  interface Known {
248
254
  state: string
249
255
  blockers: Set<string>
256
+ /** (SS-1) The health kind and quota reset last journaled (undefined: ok). */
257
+ healthKind?: string
258
+ healthResetAt?: number
250
259
  }
251
260
 
252
261
  /**
@@ -266,8 +275,17 @@ export class StatusDiff {
266
275
  const open = new Set(entry.blockers.map((b) => b.id))
267
276
  for (const b of entry.blockers) if (!known?.blockers.has(b.id)) out.push({ type: "blocker.opened", family: "session", subject, data: { blockerID: b.id, kind: b.kind } })
268
277
  for (const id of known?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", family: "session", subject, data: { blockerID: id } })
269
- if (known?.state !== entry.state) out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}) } })
270
- after.set(entry.id, { state: entry.state, blockers: open })
278
+ // (SS-1) Flat health fields (the journal's data is flat): healthKind while not ok and once as "ok" on recovery,
279
+ // healthResetAt for a quota; a change of health alone is a session.changed too. Never the provider's text.
280
+ const health = (entry as { health?: { kind: string; resetAt?: number } }).health
281
+ const healthKind = health && health.kind !== "ok" ? health.kind : undefined
282
+ const healthResetAt = healthKind !== undefined ? health!.resetAt : undefined
283
+ const healthChanged = known?.healthKind !== healthKind || known?.healthResetAt !== healthResetAt
284
+ if (known?.state !== entry.state || healthChanged) {
285
+ const healthData = healthKind !== undefined ? { healthKind, ...(healthResetAt !== undefined ? { healthResetAt } : {}) } : known?.healthKind !== undefined ? { healthKind: "ok" } : {}
286
+ out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}), ...healthData } })
287
+ }
288
+ after.set(entry.id, { state: entry.state, blockers: open, ...(healthKind !== undefined ? { healthKind } : {}), ...(healthResetAt !== undefined ? { healthResetAt } : {}) })
271
289
  }
272
290
  this.trees.set(rootID, after)
273
291
  return out