@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
@@ -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,7 @@ 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 | TeamHopRenewAction
146
+ export type DecisionAction = PermissionAction | GrantRequestAction | ActAction | TeamHopRenewAction | TeamItemAction
128
147
 
129
148
  /** Hub-created, tap-only action: an agent cannot authorize a team's hop budget renewal. */
130
149
  export interface TeamHopRenewAction {
@@ -132,6 +151,15 @@ export interface TeamHopRenewAction {
132
151
  readonly teamID: string
133
152
  }
134
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
+ }
162
+
135
163
  /**
136
164
  * (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER
137
165
  * through the acting engine (tenancy re-checked); `hold` does nothing.
@@ -233,6 +261,9 @@ export interface Decision {
233
261
  readonly question: string
234
262
  readonly options: readonly DecisionOption[]
235
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
236
267
  readonly urgency: Urgency
237
268
  readonly tier: Tier
238
269
  readonly deadline?: number
@@ -263,6 +294,8 @@ export interface AskInput {
263
294
  readonly question: string
264
295
  readonly options?: readonly { readonly id: string; readonly label: string; readonly safe?: boolean }[]
265
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
266
299
  readonly urgency?: Urgency
267
300
  readonly tier?: Tier
268
301
  /** From now. */
@@ -277,6 +310,8 @@ export interface AskInput {
277
310
  export interface AnswerInput {
278
311
  readonly option?: string
279
312
  readonly text?: string
313
+ /** (DC-1) One option per choice group. */
314
+ readonly picks?: Readonly<Record<string, string>>
280
315
  /** (CON-06) Absent = tap. */
281
316
  readonly via?: AnswerVia
282
317
  }
@@ -311,6 +346,8 @@ export interface DecisionsDeps {
311
346
  readonly changed?: (userID: string, decision: Decision) => void
312
347
  /** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
313
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>
314
351
  /** (CON-06) Admits voice/text answers; without it every voice/text answer is refused. */
315
352
  readonly via?: ViaCheck
316
353
  /** (CON-06) Whether a session is one of the user's Concierge sessions (current or past): the Decision's `as`. */
@@ -370,6 +407,9 @@ function validate(input: AskInput) {
370
407
  if (typeof option !== "string" || !has(option)) throw invalid("The recommendation must name one of the options")
371
408
  recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
372
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(", ")}`)
373
413
  const urgency = input.urgency ?? "normal"
374
414
  if (!URGENCIES.includes(urgency)) throw invalid(`urgency is one of ${URGENCIES.join(", ")}`)
375
415
  const tier = input.tier ?? "confirm"
@@ -384,7 +424,62 @@ function validate(input: AskInput) {
384
424
  }
385
425
  const expiresInMs = input.expiresInMs ?? Math.min(ASK_EXPIRES_MAX_MS, Math.max(ASK_EXPIRES_MS, (deadlineMs ?? 0) + ASK_EXPIRES_MS))
386
426
  if (deadlineMs !== undefined && deadlineMs > expiresInMs) throw invalid("The deadline is after the ask expires")
387
- 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}` : ""}`
388
483
  }
389
484
 
390
485
  export class Decisions {
@@ -410,6 +505,7 @@ export class Decisions {
410
505
  if (actor.kind === "user") throw invalid("You are @me: the user cannot ask themselves")
411
506
  if (typeof input.question !== "string") throw invalid("question must be a string")
412
507
  const v = validate(input)
508
+ if (v.choices && extra.action) throw invalid("A hub action never uses choices")
413
509
  const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
414
510
  const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
415
511
  const asConcierge = actor.kind === "session" && (await this.isConcierge(userID, actor.sessionID))
@@ -422,6 +518,8 @@ export class Decisions {
422
518
  question: message.text,
423
519
  options: v.options,
424
520
  ...(v.recommendation ? { recommendation: v.recommendation } : {}),
521
+ ...(v.choices ? { choices: v.choices } : {}),
522
+ ...(v.kind ? { kind: v.kind } : {}),
425
523
  urgency: v.urgency,
426
524
  tier: v.tier,
427
525
  ...(v.deadlineMs === undefined ? {} : { deadline: message.at + v.deadlineMs }),
@@ -486,9 +584,13 @@ export class Decisions {
486
584
  const decision = await this.settle(userID, id, answer)
487
585
  if (!decision) throw gone()
488
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
+ }
489
592
  this.emit(userID, decision)
490
- const option = decision.options.find((o) => o.id === choice.option)
491
- const said = option ? `Decision ${id}: ${option.label} [${option.id}]${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text}`
593
+ const said = answerText(decision, choice)
492
594
  const text = note ? `${said}\n${note}` : said
493
595
  return { decision, ...(await this.replyTo(userID, decision, text, by)) }
494
596
  }
@@ -502,7 +604,7 @@ export class Decisions {
502
604
  async answerByApp(
503
605
  userID: string,
504
606
  id: string,
505
- input: { readonly option?: string; readonly text?: string },
607
+ input: { readonly option?: string; readonly text?: string; readonly picks?: Readonly<Record<string, string>> },
506
608
  app: AppAnswerer,
507
609
  checks: { readonly owned: (decision: Decision) => Promise<boolean>; readonly ready?: (decision: Decision) => void },
508
610
  ): Promise<{ decision: Decision; reply?: Message }> {
@@ -519,9 +621,7 @@ export class Decisions {
519
621
  const decision = await this.settle(userID, id, answer)
520
622
  if (!decision) throw gone()
521
623
  this.emit(userID, decision)
522
- const option = decision.options.find((o) => o.id === choice.option)
523
- const by = `(answered by the app “${label ?? app.party}”)`
524
- 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}”)`)
525
625
  return { decision, ...(await this.replyTo(userID, decision, text, HUB_USER(userID))) }
526
626
  }
527
627
 
@@ -616,8 +716,29 @@ export class Decisions {
616
716
  return decision.state === "open" && this.now() >= decision.expiresAt ? { ...decision, state: "expired" } : decision
617
717
  }
618
718
 
619
- 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>> } {
620
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
+ }
621
742
  if (!decision.options.length) {
622
743
  if (input.option !== undefined) throw invalid("This decision has no options: answer with text")
623
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, HOP_CAP_CEILING, HopTracker, MessageLimiter, type MessageLimits } 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
@@ -128,14 +133,21 @@ export interface DeliveryDeps {
128
133
  readonly alert?: (userID: string, alert: DeliveryAlert) => void
129
134
  /** The first over-cap message between two seats of a team, once per persistent chain. */
130
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>
131
141
  /** A quiet, hub-authored note to the team's entry seat; never counts a message hop. */
132
142
  readonly teamHopNotice?: (userID: string, teamID: string) => Promise<string | undefined>
133
143
  /** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
134
144
  readonly expired?: (userID: string, message: Message) => void
135
145
  /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
136
146
  readonly limits?: Partial<MessageLimits>
137
- /** Configured global agent-only wake hop limit (default 8, absolute ceiling 100). */
147
+ /** Configured global agent-only wake hop limit (default 8; the live ceiling is AgentLimits', the bound HOP_ABSOLUTE_MAX). */
138
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
139
151
  /** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
140
152
  readonly relayWindowMs?: number
141
153
  /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
@@ -153,6 +165,8 @@ export interface DeliveryDeps {
153
165
  }
154
166
 
155
167
  export interface SendOptions {
168
+ /** Explicit team intake item, already authorized by the agent message route. */
169
+ readonly item?: string
156
170
  /** false: admit without starting a turn (reported queued). */
157
171
  readonly wake?: boolean
158
172
  /** Steer into a running turn, like an ask. */
@@ -179,6 +193,8 @@ export interface Sent {
179
193
  readonly woke?: boolean
180
194
  /** Why it was not (only when woke is false). */
181
195
  readonly wakeSkipped?: WakeSkip
196
+ /** Effective hop cap or wake budget, only with the corresponding wakeSkipped reason. */
197
+ readonly wakeLimit?: number
182
198
  }
183
199
 
184
200
  /** A reply or decline as sent, with the message it answered. */
@@ -279,7 +295,7 @@ export class Delivery {
279
295
  private readonly owner: string
280
296
 
281
297
  constructor(deps: DeliveryDeps) {
282
- if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit > HOP_CAP_CEILING)) throw new Error(`hopLimit must be 1–${HOP_CAP_CEILING}`)
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}`)
283
299
  this.deps = deps
284
300
  const now = (this.now = deps.now ?? Date.now)
285
301
  this.timers = deps.timers ?? defaultTimers
@@ -321,7 +337,9 @@ export class Delivery {
321
337
  if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
322
338
  const from = this.party(userID, actor)
323
339
  const resolved = await this.resolveFor(userID, actor, from, addr, kind)
324
- 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)
325
343
  }
326
344
 
327
345
  /** Like send, to every session the address names (each role holder gets its own message). */
@@ -330,11 +348,22 @@ export class Delivery {
330
348
  if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
331
349
  const from = this.party(userID, actor)
332
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))
333
352
  const sent: Sent[] = []
334
- 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))
335
354
  return sent
336
355
  }
337
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
+
338
367
  /** A notAfter validated against this engine's clock (parseNotAfter): throws invalid or expired. */
339
368
  checkNotAfter(value: unknown): number | undefined {
340
369
  return parseNotAfter(value, this.now())
@@ -355,6 +384,19 @@ export class Delivery {
355
384
  return this.hops.humanTurn(userID, sessionID)
356
385
  }
357
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
+
358
400
  async notifyTeamHopCap(userID: string, teamID: string, decisionID: string): Promise<void> {
359
401
  const entry = await this.deps.teamHopNotice?.(userID, teamID)
360
402
  if (!entry) return
@@ -484,7 +526,7 @@ export class Delivery {
484
526
  return { message }
485
527
  }
486
528
 
487
- 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> {
488
530
  const sessionID = entry.sessionID
489
531
  const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
490
532
  await this.deps.inbound?.outbound?.(userID, from, kind)
@@ -493,6 +535,19 @@ export class Delivery {
493
535
  await this.limiter.admit(userID, from, recipient)
494
536
  const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
495
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
+ }
496
551
  const message = await this.messages.send(userID, {
497
552
  from,
498
553
  to: addr,
@@ -500,6 +555,7 @@ export class Delivery {
500
555
  kind,
501
556
  text,
502
557
  hop,
558
+ ...(options.item ? { item: options.item } : {}),
503
559
  ...(generation === undefined ? {} : { generation }),
504
560
  ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
505
561
  ...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
@@ -509,14 +565,20 @@ export class Delivery {
509
565
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
510
566
  const team = "team" in resolved ? resolved.team : undefined
511
567
  const explicitTeamID = typeof team === "object" && team !== null && "teamID" in team && typeof team.teamID === "string" ? team.teamID : undefined
512
- const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID)
513
- const limit = Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP)
514
- const skipped = await this.wakeFor(userID, message, unwanted, limit, context?.teamID)
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
515
577
  if (kind === "ask" && unwanted === undefined && hop <= limit) {
516
578
  const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
517
579
  await this.deps.state.put(userID, REWAKE, message.id, rewake)
518
580
  }
519
- 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))
520
582
  }
521
583
 
522
584
  private schedule(): void {
@@ -611,7 +673,7 @@ export class Delivery {
611
673
  replyTo: string,
612
674
  options: SendOptions,
613
675
  kind: "reply" | "decline",
614
- 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 }>,
615
677
  ): Promise<Replied> {
616
678
  const from = this.party(userID, actor)
617
679
  const original = await this.messages.get(userID, replyTo)
@@ -627,25 +689,43 @@ export class Delivery {
627
689
  if (to) await this.limiter.admit(userID, from, to)
628
690
  const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
629
691
  const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
630
- 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 } : {}) })
631
704
  const { message } = answered
632
705
  await this.hops.received(userID, message.recipient, hop, from)
633
706
  this.users.add(userID)
634
707
  if (message.recipient.kind !== "session") return answered
635
708
  const sessionID = message.recipient.sessionID
636
709
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
637
- const context = await this.deps.teams?.hopContext(userID, from, message.recipient)
638
- const skipped = await this.wakeFor(userID, message, unwanted, Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP), context?.teamID)
639
- 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 }
640
720
  }
641
721
 
642
722
  /**
643
723
  * Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
644
724
  * the hop cap (alerting once at the cap), or no wake budget left.
645
725
  */
646
- private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.hopLimit ?? HOP_CAP, teamID?: string): Promise<WakeSkip | undefined> {
647
- if (unwanted) return unwanted
648
- if (message.recipient.kind !== "session") return "wake_false"
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" }
649
729
  if (message.hop > limit) {
650
730
  if (teamID && message.from.kind === "session") {
651
731
  try {
@@ -662,9 +742,41 @@ export class Delivery {
662
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`,
663
743
  })
664
744
  }
665
- 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 } : {}) }
666
778
  }
667
- return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
779
+ return {}
668
780
  }
669
781
 
670
782
  /** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
@@ -702,9 +814,9 @@ export class Delivery {
702
814
  }
703
815
 
704
816
  /** Delivers now, or queues the message as pending when no spoke takes it. */
705
- 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> {
706
818
  const wake = skipped === undefined
707
- const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
819
+ const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped, ...(wakeLimit !== undefined ? { wakeLimit } : {}) }
708
820
  // Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
709
821
  // one starts then carries them, instead of them arriving inside it.
710
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
@@ -120,6 +120,7 @@ export interface LaunchEngineDeps {
120
120
  /** This process (pod), as placement lease owner. */
121
121
  readonly owner: string
122
122
  readonly caps?: HubLaunches
123
+ readonly currentCaps?: () => { readonly maxQueued: number; readonly maxActive: number; readonly perHour: number; readonly lostAfterMinutes?: number | false }
123
124
  readonly nodes: EngineNodes
124
125
  readonly nodeOps: EngineNodeOps
125
126
  readonly registry: EngineRegistry
@@ -334,7 +335,10 @@ export class LaunchEngine {
334
335
  private readonly timers: Timers
335
336
  private tickTimer: unknown
336
337
  /** How long the reporting node instance may omit a running launch (NODE_UNREPORTED_MS, or caps.lostAfterMinutes; undefined: never). */
337
- private readonly nodeLostMs: number | undefined
338
+ private get nodeLostMs(): number | undefined {
339
+ const lostAfter = this.deps.currentCaps?.().lostAfterMinutes ?? this.deps.caps?.lostAfterMinutes
340
+ return lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
341
+ }
338
342
  /** Machines being reconciled now. */
339
343
  private readonly reconciling = new Set<string>()
340
344
  /** Reconcile cleanup retries per launch, and the launches whose failing retry was logged. */
@@ -352,8 +356,6 @@ export class LaunchEngine {
352
356
  this.now = deps.now ?? Date.now
353
357
  this.timers = deps.timers ?? defaultTimers
354
358
  this.store = new LaunchStore({ state: deps.state, now: this.now })
355
- const lostAfter = deps.caps?.lostAfterMinutes
356
- this.nodeLostMs = lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
357
359
  const { registry } = deps
358
360
  this.placement = new Placement({
359
361
  store: this.store,
@@ -369,6 +371,7 @@ export class LaunchEngine {
369
371
  locks: deps.locks,
370
372
  owner: deps.owner,
371
373
  ...(deps.caps ? { caps: deps.caps } : {}),
374
+ ...(deps.currentCaps ? { currentCaps: deps.currentCaps } : {}),
372
375
  ...(deps.launchers ? { launchers: deps.launchers } : {}),
373
376
  now: this.now,
374
377
  })