@huynle/hub 0.8.0 → 0.8.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 (112) hide show
  1. package/CHANGELOG.md +213 -1
  2. package/README.md +84 -15
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +649 -242
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  9. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  10. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  11. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  12. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  13. package/packages/cli/src/cli.ts +3 -3
  14. package/packages/cli/src/http.ts +1 -0
  15. package/packages/cli/src/launches.ts +9 -3
  16. package/packages/cli/src/login.ts +2 -2
  17. package/packages/cli/src/mcp.ts +7 -3
  18. package/packages/cli/src/messaging.ts +4 -2
  19. package/packages/cli/src/profiles-mcp.ts +301 -0
  20. package/packages/cli/src/setup/commands.ts +20 -2
  21. package/packages/cli/src/spoke-services.ts +1 -1
  22. package/packages/cli/src/teams-mcp.ts +59 -2
  23. package/packages/codex/package.json +1 -1
  24. package/packages/codex/src/paths.ts +3 -3
  25. package/packages/node/package.json +1 -1
  26. package/packages/node/src/config.ts +10 -2
  27. package/packages/node/src/opencode-warm.ts +27 -8
  28. package/packages/node/src/run.ts +3 -2
  29. package/packages/preset/package.json +1 -1
  30. package/packages/protocol/package.json +1 -1
  31. package/packages/protocol/src/index.ts +7 -1
  32. package/packages/protocol/src/launch.ts +14 -0
  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/errors.ts +1 -0
  37. package/packages/sdk/src/index.ts +1 -0
  38. package/packages/sdk/src/types.ts +22 -2
  39. package/packages/sdk/src/wake.ts +20 -0
  40. package/packages/spoke-sdk/package.json +1 -1
  41. package/src/central/agent/acting.ts +3 -0
  42. package/src/central/agent/audit.ts +1 -1
  43. package/src/central/agent/decisions.ts +58 -2
  44. package/src/central/agent/delivery.ts +101 -27
  45. package/src/central/agent/errors.ts +2 -0
  46. package/src/central/agent/escalation.ts +10 -2
  47. package/src/central/agent/events.ts +7 -1
  48. package/src/central/agent/launch-engine.ts +173 -30
  49. package/src/central/agent/launch-routes.ts +20 -9
  50. package/src/central/agent/launch-spec.ts +26 -2
  51. package/src/central/agent/launch-store.ts +55 -2
  52. package/src/central/agent/message-limits.ts +89 -11
  53. package/src/central/agent/message-routes.ts +8 -3
  54. package/src/central/agent/messages.ts +3 -2
  55. package/src/central/agent/placement.ts +93 -18
  56. package/src/central/agent/read-routes.ts +3 -0
  57. package/src/central/agent/routes.ts +4 -2
  58. package/src/central/agent/webhooks.ts +17 -2
  59. package/src/central/artifacts/jobs-routes.ts +4 -1
  60. package/src/central/concierge/board-routes.ts +13 -2
  61. package/src/central/concierge/inbound.ts +4 -7
  62. package/src/central/concierge/prompt.ts +4 -3
  63. package/src/central/concierge/proposals.ts +8 -2
  64. package/src/central/concierge/routes.ts +62 -2
  65. package/src/central/concierge/session-routes.ts +62 -6
  66. package/src/central/concierge/supervisor.ts +34 -7
  67. package/src/central/concierge/watchlist-routes.ts +4 -2
  68. package/src/central/config.ts +21 -5
  69. package/src/central/http.ts +38 -15
  70. package/src/central/main.ts +1 -1
  71. package/src/central/registry.ts +26 -1
  72. package/src/central/teams/intake.ts +20 -6
  73. package/src/central/teams/staffing.ts +22 -0
  74. package/src/central/workspaces/sidechats-routes.ts +1 -1
  75. package/src/index.ts +39 -18
  76. package/src/migrate.ts +109 -3
  77. package/src/spoke/env-inject.ts +8 -1
  78. package/src/spoke/hub-tool.ts +24 -10
  79. package/src/spoke/launchers.ts +6 -4
  80. package/src/spoke/listing.ts +18 -6
  81. package/src/spoke/localhub.ts +14 -4
  82. package/src/spoke/locations.ts +40 -0
  83. package/src/spoke/session-context.ts +107 -27
  84. package/src/spoke/spoke.ts +13 -5
  85. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  86. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  87. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  88. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  89. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  90. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  91. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  92. package/web/dist/assets/index-BabrNDB3.css +1 -0
  93. package/web/dist/assets/index-lIOfSYck.js +34 -0
  94. package/web/dist/assets/live-BDq0AABg.js +5 -0
  95. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  96. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
  97. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
  98. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
  99. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  100. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  101. package/web/dist/index.html +4 -4
  102. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  103. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  104. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  105. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  106. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  107. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  108. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  109. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  110. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  111. package/web/dist/assets/live-YyTjE47g.js +0 -5
  112. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -7,13 +7,16 @@
7
7
  // {feature: "deliver"} and nothing is stored. No fallback through steer/turn.
8
8
  // - Modes: plain msg → delivery "queue" (after the running turn), ask/reply/decline/urgent → "steer" (also the
9
9
  // first msg after a primer to an idle session: the two then share one model call, quirk hzmcqwpj); wake
10
- // unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget.
10
+ // unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget; Sent
11
+ // says whether it woke (woke) and, when not, why (wakeSkipped: wake_false | subagent | hop_cap | budget).
11
12
  // - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
12
13
  // one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
13
14
  // never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
14
15
  // - Pending: no deliver-capable connection reports the recipient (offline) or every one failed (including an
15
16
  // OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
16
- // PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). In a
17
+ // PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). One
18
+ // still pending after PENDING_MS expires (the loop's tick, or a flush that finds it): delivery "expired", and its
19
+ // sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal). In a
17
20
  // cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
18
21
  // entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
19
22
  // session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
@@ -37,8 +40,12 @@
37
40
  // for the user (their stored-only inbox; nothing goes to a spoke); a msg alerts the user ("user"), an ask is a
38
41
  // Decision's carrier (./decisions pushes it). The user cannot message @me; `@me.project` is malformed.
39
42
  // - Loop protection (./message-limits): pair/user rate limits before storing, hop per message, human turns
40
- // (console, voice, typed in the harness) reset a session's chain (humanTurn).
43
+ // (console, voice, typed in the harness) reset a session's chain (humanTurn). Apps keep no depth (S1): a new
44
+ // msg/ask from an app is hop 1 plus its relay depth (what OTHER sessions sent it in the last 5 minutes, so a
45
+ // relay loop through an app still reaches the hop cap), an app's reply or decline counts up from the message it
46
+ // answers, and nothing sent to an app raises a persistent depth.
41
47
  import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
48
+ import type { WakeSkip } from "../../../packages/sdk/src/types.ts"
42
49
  import { isDeliverResult } from "../../protocol"
43
50
  import { randomBytes } from "node:crypto"
44
51
  import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
@@ -80,8 +87,8 @@ export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<Deliv
80
87
  * ask stayed unanswered by an idle recipient after every re-wake.
81
88
  */
82
89
  export interface DeliveryAlert {
83
- /** user: a session or app sent the user (@me) a message. */
84
- readonly kind: "hopCap" | "unanswered" | "user"
90
+ /** user: a session or app sent the user (@me) a message; expired: the user's message stayed pending for 24 h. */
91
+ readonly kind: "hopCap" | "unanswered" | "user" | "expired"
85
92
  /** The recipient session (user: the sending session, "" for an app). */
86
93
  readonly sessionID: string
87
94
  readonly messageID: string
@@ -113,8 +120,12 @@ export interface DeliveryDeps {
113
120
  /** Labels of a session sender (default: its @name from the directory). */
114
121
  readonly describe?: (userID: string, sessionID: string) => PartyInfo
115
122
  readonly alert?: (userID: string, alert: DeliveryAlert) => void
123
+ /** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
124
+ readonly expired?: (userID: string, message: Message) => void
116
125
  /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
117
126
  readonly limits?: Partial<MessageLimits>
127
+ /** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
128
+ readonly relayWindowMs?: number
118
129
  /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
119
130
  readonly users?: () => Iterable<string> | Promise<Iterable<string>>
120
131
  /** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
@@ -136,6 +147,11 @@ export interface SendOptions {
136
147
  readonly urgent?: boolean
137
148
  /** Asks only. */
138
149
  readonly expiresInMs?: number
150
+ /**
151
+ * Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
152
+ * larger of its own and this). Team intake passes the submitting session's depth + 1.
153
+ */
154
+ readonly chainHop?: number
139
155
  }
140
156
 
141
157
  export interface Sent {
@@ -144,6 +160,8 @@ export interface Sent {
144
160
  readonly delivery?: DeliveryState
145
161
  /** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
146
162
  readonly woke?: boolean
163
+ /** Why it was not (only when woke is false). */
164
+ readonly wakeSkipped?: WakeSkip
147
165
  }
148
166
 
149
167
  /** A reply or decline as sent, with the message it answered. */
@@ -247,7 +265,7 @@ export class Delivery {
247
265
  this.newID = deps.newID ?? newMessageID
248
266
  this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
249
267
  this.messages = new Messages({ state: deps.state, now, newID: this.newID })
250
- this.hops = new HopTracker({ state: deps.state, now })
268
+ this.hops = new HopTracker({ state: deps.state, now, ...(deps.relayWindowMs === undefined ? {} : { relayWindowMs: deps.relayWindowMs }) })
251
269
  this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
252
270
  // An explicit (re)name primes again, so the session learns its new address.
253
271
  deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
@@ -367,6 +385,11 @@ export class Delivery {
367
385
  return
368
386
  }
369
387
  for (const userID of users) {
388
+ try {
389
+ await this.expirePending(userID)
390
+ } catch (cause) {
391
+ console.error(`[hub] pending expiry failed: ${cause instanceof Error ? cause.message : String(cause)}`)
392
+ }
370
393
  try {
371
394
  await this.rewake(userID)
372
395
  } catch (cause) {
@@ -404,7 +427,7 @@ export class Delivery {
404
427
  const from = this.party(userID, actor)
405
428
  const recipient: Party = { kind: "user" }
406
429
  await this.limiter.admit(userID, from, recipient)
407
- const hop = await this.hops.next(userID, from)
430
+ const hop = await this.hops.next(userID, from, { to: recipient })
408
431
  const message = await this.messages.send(userID, {
409
432
  from,
410
433
  to: USER_ADDRESS,
@@ -428,7 +451,7 @@ export class Delivery {
428
451
  await this.deps.inbound?.admit(userID, from, sessionID, kind)
429
452
  this.gate(userID, sessionID)
430
453
  await this.limiter.admit(userID, from, recipient)
431
- const hop = await this.hops.next(userID, from)
454
+ const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
432
455
  const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
433
456
  const message = await this.messages.send(userID, {
434
457
  from,
@@ -440,15 +463,15 @@ export class Delivery {
440
463
  ...(generation === undefined ? {} : { generation }),
441
464
  ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
442
465
  })
443
- await this.hops.received(userID, recipient, hop)
466
+ await this.hops.received(userID, recipient, hop, from)
444
467
  this.users.add(userID)
445
- const wanted = options.wake !== false && entry.parentID === undefined
446
- const wake = await this.wakeFor(userID, message, wanted)
447
- if (kind === "ask" && wanted && !overHopCap(hop)) {
468
+ const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
469
+ const skipped = await this.wakeFor(userID, message, unwanted)
470
+ if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
448
471
  const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
449
472
  await this.deps.state.put(userID, REWAKE, message.id, rewake)
450
473
  }
451
- return this.deliver(userID, message, sessionID, wake, options.urgent === true)
474
+ return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
452
475
  }
453
476
 
454
477
  private schedule(): void {
@@ -555,33 +578,38 @@ export class Delivery {
555
578
  }
556
579
  if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
557
580
  if (to) await this.limiter.admit(userID, from, to)
558
- const hop = await this.hops.next(userID, from)
581
+ const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
559
582
  const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
560
583
  const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
561
584
  const { message } = answered
562
- await this.hops.received(userID, message.recipient, hop)
585
+ await this.hops.received(userID, message.recipient, hop, from)
563
586
  this.users.add(userID)
564
587
  if (message.recipient.kind !== "session") return answered
565
588
  const sessionID = message.recipient.sessionID
566
- const wake = await this.wakeFor(userID, message, options.wake !== false && !this.isSubagent(userID, sessionID))
567
- return { ...(await this.deliver(userID, message, sessionID, wake, true)), original: answered.original }
589
+ const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
590
+ const skipped = await this.wakeFor(userID, message, unwanted)
591
+ return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
568
592
  }
569
593
 
570
- /** Whether the message may start a turn: wanted, under the hop cap (alerting once at the cap), wake budget left. */
571
- private async wakeFor(userID: string, message: Message, wanted: boolean): Promise<boolean> {
572
- if (!wanted || message.recipient.kind !== "session") return false
594
+ /**
595
+ * Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
596
+ * the hop cap (alerting once at the cap), or no wake budget left.
597
+ */
598
+ private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
599
+ if (unwanted) return unwanted
600
+ if (message.recipient.kind !== "session") return "wake_false"
573
601
  if (overHopCap(message.hop)) {
574
602
  if (message.hop === HOP_CAP + 1) {
575
603
  this.deps.alert?.(userID, {
576
604
  kind: "hopCap",
577
605
  sessionID: message.recipient.sessionID,
578
606
  messageID: message.id,
579
- text: `Sessions messaged each other ${HOP_CAP} times without you; further messages no longer wake them`,
607
+ 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`,
580
608
  })
581
609
  }
582
- return false
610
+ return "hop_cap"
583
611
  }
584
- return this.limiter.wake(userID, message.recipient)
612
+ return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
585
613
  }
586
614
 
587
615
  /** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
@@ -619,16 +647,18 @@ export class Delivery {
619
647
  }
620
648
 
621
649
  /** Delivers now, or queues the message as pending when no spoke takes it. */
622
- private async deliver(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent> {
650
+ private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
651
+ const wake = skipped === undefined
652
+ const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
623
653
  // Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
624
654
  // one starts then carries them, instead of them arriving inside it.
625
655
  if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
626
656
  const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
627
- if (delivered) return { ...delivered, woke: wake }
657
+ if (delivered) return { ...delivered, ...woke }
628
658
  const at = this.now()
629
659
  const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
630
660
  await this.deps.state.put(userID, PENDING, message.id, pending)
631
- return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", woke: wake }
661
+ return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
632
662
  }
633
663
 
634
664
  /** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
@@ -671,7 +701,11 @@ export class Delivery {
671
701
  this.flushStats.skipped++
672
702
  continue
673
703
  }
674
- const message = value.expiresAt > this.now() ? await this.messages.get(userID, value.messageID) : undefined
704
+ if (value.expiresAt <= this.now()) {
705
+ await this.expireEntry(userID, key, value, version)
706
+ continue
707
+ }
708
+ const message = await this.messages.get(userID, value.messageID)
675
709
  // Gone, or an ask that closed (expired) while it waited: nothing to deliver.
676
710
  if (!message || (message.kind === "ask" && message.state !== "open")) {
677
711
  await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
@@ -695,6 +729,46 @@ export class Delivery {
695
729
  }
696
730
  }
697
731
 
732
+ /** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
733
+ private async expirePending(userID: string): Promise<void> {
734
+ const t = this.now()
735
+ for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
736
+ if (value.expiresAt <= t) await this.expireEntry(userID, key, value, version)
737
+ }
738
+ }
739
+
740
+ /**
741
+ * A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
742
+ * told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
743
+ * journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
744
+ */
745
+ private async expireEntry(userID: string, key: string, value: Pending, version: number): Promise<void> {
746
+ if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
747
+ const current = await this.messages.get(userID, value.messageID)
748
+ if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
749
+ const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
750
+ const what = `${message.kind} ${message.id} to ${message.to}`
751
+ try {
752
+ this.deps.expired?.(userID, message)
753
+ } catch (cause) {
754
+ console.error(`[hub] pending expiry journal failed: ${cause instanceof Error ? cause.message : String(cause)}`)
755
+ }
756
+ const { from } = message
757
+ if (from.kind === "user") {
758
+ this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: its session stayed offline or busy for 24 h` })
759
+ } else if (from.kind === "session") {
760
+ const id = this.newID()
761
+ await this.request(userID, from.sessionID, {
762
+ op: "deliver",
763
+ sessionID: from.sessionID,
764
+ messageID: id,
765
+ text: `[hub] notice · ${what} was not delivered: its session stayed offline or busy for 24 h · ${id}`,
766
+ wake: false,
767
+ meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
768
+ })
769
+ }
770
+ }
771
+
698
772
  /** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
699
773
  private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
700
774
  const { locks } = this.deps
@@ -35,6 +35,8 @@ export const AGENT_ERROR_STATUS = {
35
35
  internal: 500,
36
36
  /** The machine is offline (actions only; messages queue instead). */
37
37
  offline: 503,
38
+ /** (FX-07) The hub's state did not answer in time (`retryAfter`, seconds): refused, try again. */
39
+ busy: 503,
38
40
  } as const satisfies Record<string, number>
39
41
 
40
42
  export type AgentErrorCode = keyof typeof AGENT_ERROR_STATUS
@@ -25,7 +25,7 @@ import type { BlockerRecommendation } from "../../status"
25
25
  import type { Escalation } from "./acting"
26
26
  import type { AgentActor, UserActor } from "./actor"
27
27
  import type { AgentAudit } from "./audit"
28
- import { type ActOutcome, type Decision, type DecisionActor, type Decisions, WHY_MAX } from "./decisions"
28
+ import { type ActOutcome, type Decision, type DecisionActor, type Decisions, decisionRequest, WHY_MAX } from "./decisions"
29
29
  import { type Party, partyFromActor, partyKey, partyLabel } from "./envelope"
30
30
  import { AgentError } from "./errors"
31
31
  import type { Message } from "./messages"
@@ -168,7 +168,15 @@ export class Escalations {
168
168
  about: [escalation.sessionID],
169
169
  },
170
170
  {
171
- action: { kind: "permission", requestID: escalation.requestID, rootID: escalation.rootID, sessionID: escalation.sessionID, always: escalation.always },
171
+ action: {
172
+ kind: "permission",
173
+ requestID: escalation.requestID,
174
+ rootID: escalation.rootID,
175
+ sessionID: escalation.sessionID,
176
+ always: escalation.always,
177
+ ...(escalation.request ? { request: decisionRequest(escalation.request) } : {}),
178
+ ...(escalation.evaluation.reason ? { reason: escalation.evaluation.reason.slice(0, WHY_MAX) } : {}),
179
+ },
172
180
  quiet: pushes > 1,
173
181
  },
174
182
  )
@@ -193,7 +193,13 @@ export function launchEvent(launch: Launch): EventInput {
193
193
  type: "launch.changed",
194
194
  family: "launch",
195
195
  subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
196
- data: { state: launch.state, ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}), ...(launch.name !== undefined ? { name: launch.name } : {}) },
196
+ data: {
197
+ state: launch.state,
198
+ ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
199
+ ...(launch.name !== undefined ? { name: launch.name } : {}),
200
+ // FX-02: a queued launch's wait kind (never its text); the engine journals a change of kind only.
201
+ ...(launch.state === "queued" && launch.waiting ? { wait: launch.waiting.wait } : {}),
202
+ },
197
203
  ...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
198
204
  }
199
205
  }