@huynle/hub 0.8.0 → 0.8.2

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 (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  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 +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -16,6 +16,9 @@
16
16
  // {verdict, reason, tier, grants}. The `escalate` hook (P3-T8 turns it into a Decision) gets the escalation
17
17
  // first; a request without parsed details, or truncated, is always needs_user (tap).
18
18
  // · deny, answer, prompt and stop need only the grant's verb (a reject is always safe).
19
+ // · (R-03) the CURRENT Concierge session's approve without any usable grant is needs_user too (a Decision at the hub's tier;
20
+ // the hub's never rules still forbid; self-tree still applies), never a bare refusal; other agents stay forbidden.
21
+ // A Concierge escalation recommends approve only when it passed `recommend: true`.
19
22
  // - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
20
23
  // paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
21
24
  // - Races: the request answered elsewhere first is gone (410).
@@ -83,8 +86,12 @@ export interface Escalation {
83
86
  readonly request?: PermissionRequest
84
87
  readonly always: boolean
85
88
  readonly evaluation: Evaluation
86
- /** The agent's reason (`--why`): its recommendation to approve. */
89
+ /** The agent's reason (`--why`): its recommendation to approve (a Concierge's: only with `recommend`). */
87
90
  readonly why?: string
91
+ /** (R-03) The actor is a Concierge session: its escalation recommends approve only with `recommend`. */
92
+ readonly concierge?: true
93
+ /** (R-03) The Concierge itself advises approving (absent: the user only asked it to try). */
94
+ readonly recommend?: true
88
95
  }
89
96
 
90
97
  /** The escalation hook: answers the Decision it opened (or nothing); a throw only loses the Decision. */
@@ -111,11 +118,18 @@ export interface ActingDeps {
111
118
  * "always", even inside its grant: needs_user, a tap Decision. A failed read counts as yes (fail closed).
112
119
  */
113
120
  readonly conciergeActor?: (userID: string, sessionID: string) => Promise<boolean>
121
+ /**
122
+ * (R-03 verify) Whether the acting session is the user's CURRENT Concierge session (never a retired one). Only it
123
+ * may escalate an approve without a grant; a failed read counts as no (fail closed: refused).
124
+ */
125
+ readonly currentConcierge?: (userID: string, sessionID: string) => Promise<boolean>
114
126
  }
115
127
 
116
128
  /** (CON-13 decision) Why the Concierge escalates a covered request. */
117
129
  export const CONCIERGE_TAP_REASON = "Shell, outside folders and tools always need the user's tap"
118
130
  export const CONCIERGE_ALWAYS_REASON = "The Concierge never answers “always”: the user decides"
131
+ /** (R-03) Why the Concierge escalates an approval it holds no grant for. */
132
+ export const CONCIERGE_NO_GRANT_REASON = "No grant lets it approve here: the user decides"
119
133
 
120
134
  /** Who acts, for which user. */
121
135
  export interface Caller {
@@ -127,6 +141,8 @@ export interface ApproveInput {
127
141
  readonly requestID: string
128
142
  readonly always?: boolean
129
143
  readonly why?: string
144
+ /** (R-03) The Concierge advises approving: its escalation recommends approve (other agents' why always does). */
145
+ readonly recommend?: boolean
130
146
  }
131
147
 
132
148
  export interface Approved {
@@ -282,6 +298,17 @@ function neverForms(request: PermissionRequest): PermissionRequest[] {
282
298
 
283
299
  const patterns = (texts: readonly string[]): Pattern[] => texts.flatMap((t) => parsePattern(t) ?? [])
284
300
 
301
+ /** Forbidden when a never rule (the hub's, or a usable grant's) matches the request as asked or normalised (saves too, for always). */
302
+ function checkNever(request: PermissionRequest, rules: ReadonlyArray<{ grant: Grant; never: Pattern[] }>, hubNever: readonly Pattern[], always: boolean): void {
303
+ const sets = rules.length ? rules.map((r) => ({ id: r.grant.id, never: r.never })) : [{ id: undefined, never: [] as Pattern[] }]
304
+ for (const form of neverForms(request)) {
305
+ for (const { id, never } of sets) {
306
+ const verdict = evaluatePermission(form, { grant: { allow: [], never }, hubNever, always, allowAlways: true })
307
+ if (verdict.verdict === "never") throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
308
+ }
309
+ }
310
+ }
311
+
285
312
  const invalid = (message: string) => new AgentError("invalid", message)
286
313
  const forbidden = (message: string) => new AgentError("forbidden", message)
287
314
  const offline = () => new AgentError("offline", "No spoke reports this session now")
@@ -303,6 +330,8 @@ export class Acting {
303
330
  const concierge = await this.isConcierge(caller)
304
331
  return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
305
332
  selfTree: true,
333
+ // (R-03) The Concierge without a grant escalates (needs_user, a Decision) instead of being refused.
334
+ ...(concierge ? { withoutGrant: () => this.isCurrentConcierge(caller) } : {}),
306
335
  choose: (usable) => this.choosePermission(usable, origin.request, always, concierge),
307
336
  escalation: (evaluation) => ({
308
337
  verb: "approve",
@@ -314,6 +343,8 @@ export class Acting {
314
343
  always,
315
344
  evaluation,
316
345
  ...(input.why ? { why: input.why } : {}),
346
+ ...(concierge ? { concierge: true as const } : {}),
347
+ ...(concierge && input.recommend === true ? { recommend: true as const } : {}),
317
348
  }),
318
349
  run: async (grant) => {
319
350
  const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision })
@@ -519,9 +550,20 @@ export class Acting {
519
550
  if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
520
551
  if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
521
552
  }
553
+ // A prompt commits a staged revert (OpenCode, quirk 9budy9na): only the console's Send (or Restore) ends one.
554
+ // Checked after the grant (an agent without one learns nothing about the session) and audited like any refusal.
555
+ const notStaged = () => {
556
+ if ((this.deps.registry.session(target.sessionID) as { revert?: unknown } | undefined)?.revert) {
557
+ throw new AgentError("conflict", "A revert is staged in this session: a prompt would make it permanent", { hint: "Restore it or send from the session's console first" })
558
+ }
559
+ }
522
560
  return this.act(caller, "prompt", target, {}, input.why, {
561
+ check: notStaged,
523
562
  run: async (grant) => {
524
- if (!grant) return this.userPrompt(target.sessionID, text, images, input.intent)
563
+ if (!grant) {
564
+ notStaged()
565
+ return this.userPrompt(target.sessionID, text, images, input.intent)
566
+ }
525
567
  return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
526
568
  },
527
569
  })
@@ -584,6 +626,8 @@ export class Acting {
584
626
  plan: {
585
627
  /** A session never acts in its own tree (permissions and forms). */
586
628
  readonly selfTree?: boolean
629
+ /** (R-03) No usable grant is not forbidden: `choose` gets none and throws NeedsUser (or forbidden). */
630
+ readonly withoutGrant?: () => Promise<boolean>
587
631
  /** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
588
632
  readonly choose?: (usable: Grant[]) => Grant
589
633
  readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
@@ -610,7 +654,7 @@ export class Acting {
610
654
  acting.ensureActing(userID)
611
655
  await this.guard(userID, target)
612
656
  const usable = await grants.usable(actor, verb, { directory: target.directory })
613
- if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
657
+ if (!usable.length && !(plan.choose && plan.withoutGrant && (await plan.withoutGrant()))) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
614
658
  if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
615
659
  throw forbidden("A session never answers requests in its own session tree")
616
660
  }
@@ -670,24 +714,32 @@ export class Acting {
670
714
  }
671
715
  }
672
716
 
717
+ /** (R-03 verify) The actor is the user's current Concierge session; a failed read (or no reader) is no. */
718
+ private async isCurrentConcierge(caller: Caller): Promise<boolean> {
719
+ const { actor } = caller
720
+ if (actor.kind !== "session" || !this.deps.currentConcierge) return false
721
+ try {
722
+ return await this.deps.currentConcierge(caller.userID, actor.sessionID)
723
+ } catch {
724
+ return false
725
+ }
726
+ }
727
+
673
728
  /**
674
729
  * The grant that covers the request, after every never rule; NeedsUser when none does. (CON-13 decision)
675
730
  * `concierge`: a Concierge actor's "always", or a covered request of the tap risk tier, is NeedsUser at tap tier.
731
+ * (R-03 verify) Every never rule (the hub's and each usable grant's, also with no grant) is checked first, before
732
+ * any NeedsUser: a forbidden request never becomes a Decision, "always" or not.
676
733
  */
677
734
  private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean, concierge = false): Grant {
678
735
  const hubNever = this.deps.acting.hubNever()
679
736
  const ids = usable.map((g) => g.id)
737
+ const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
738
+ if (request) checkNever(request, rules, hubNever, always)
680
739
  if (concierge && always) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_ALWAYS_REASON, tier: "tap", grants: ids })
681
740
  if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
682
- const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
683
- for (const form of neverForms(request)) {
684
- for (const { grant, allow, never } of rules) {
685
- const verdict = evaluatePermission(form, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
686
- if (verdict.verdict === "never") {
687
- throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${grant.id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
688
- }
689
- }
690
- }
741
+ // (R-03) The current Concierge holds no grant: anything the never rules let through is the user's, at the hub's tier.
742
+ if (!usable.length) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_NO_GRANT_REASON, tier: request.truncated ? "tap" : riskTier(request, hubNever), grants: [] })
691
743
  const allNever = [...hubNever, ...rules.flatMap((r) => r.never)]
692
744
  if (request.truncated) throw new NeedsUser({ verdict: "outside", reason: "The request was too large to check in full", tier: "tap", grants: ids })
693
745
  let reason: string | undefined
@@ -790,6 +842,12 @@ export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
790
842
  return ["forbidden", "refused"]
791
843
  case "rate_limited":
792
844
  return ["rate_limited", "refused"]
845
+ // Allowed, but the session's state refuses it now (a staged revert).
846
+ case "conflict":
847
+ return ["allow", "refused"]
848
+ // (FX-07) The hub's state did not answer in time: refused, and Activity says the hub was busy.
849
+ case "busy":
850
+ return ["busy", "refused"]
793
851
  case "needs_user":
794
852
  return ["needs_user", "refused"]
795
853
  case "gone":
@@ -29,7 +29,7 @@ export const AUDIT_PAGE_DEFAULT = 100
29
29
  export const AUDIT_PAGE_MAX = 500
30
30
 
31
31
  /** The policy's decision: allowed, or why not. */
32
- export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited"
32
+ export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited" | "busy"
33
33
  /** What happened: done, failed, raced (gone), the spoke could not (offline, unsupported), escalated or refused. */
34
34
  export type AuditOutcome = "ok" | "failed" | "gone" | "offline" | "unsupported" | "escalated" | "refused"
35
35
 
@@ -106,6 +106,8 @@ export interface ActAction {
106
106
  readonly target: string
107
107
  /** The verb's validated input (requestID, formID, answer, text). */
108
108
  readonly input: Readonly<Record<string, unknown>>
109
+ /** (FX-05) deny: the permission it would deny, for the console's card. */
110
+ readonly request?: DecisionRequest
109
111
  }
110
112
 
111
113
  /** An escalated permission request: the answer replies to it as the user. */
@@ -118,6 +120,56 @@ export interface PermissionAction {
118
120
  readonly sessionID: string
119
121
  /** The agent asked to approve it always. */
120
122
  readonly always: boolean
123
+ /** (FX-05) What it asks, for the console's card (absent when the hub did not know the request's details). */
124
+ readonly request?: DecisionRequest
125
+ /** (FX-05) Why the hub sent it to the user (the evaluation's reason). */
126
+ readonly reason?: string
127
+ }
128
+
129
+ /**
130
+ * (FX-05) A permission request as a Decision shows it (bounded): its kind, what it applies to and the patterns an
131
+ * "always" would save. What was not stored is recorded, so a shortened command never reads as complete: the totals
132
+ * when items were dropped, each cut item with its full length, and `truncated` when the hub received the request
133
+ * already shortened (src/status.ts permissionRequest). The console renders it as text; the Concierge's Decision reads
134
+ * leave it out (concierge/board-routes.ts conciergeDecisionView).
135
+ */
136
+ export interface DecisionRequest {
137
+ readonly action: string
138
+ readonly resources: readonly string[]
139
+ readonly save?: readonly string[]
140
+ /** How many resources the request had, when more than were stored. */
141
+ readonly resourcesTotal?: number
142
+ /** How many save patterns it had, when more than were stored. */
143
+ readonly savesTotal?: number
144
+ /** Stored items cut at DECISION_REQUEST_TEXT characters, with their full length. */
145
+ readonly cut?: readonly { readonly field: "resources" | "save"; readonly index: number; readonly length: number }[]
146
+ /** The hub received the request already shortened. */
147
+ readonly truncated?: true
148
+ }
149
+
150
+ /** Resources (and save patterns) a Decision keeps of a request, and the characters of each. */
151
+ export const DECISION_REQUEST_ITEMS = 20
152
+ export const DECISION_REQUEST_TEXT = 2_000
153
+
154
+ /** A permission request, bounded for a Decision, with what was not stored. */
155
+ export function decisionRequest(request: { readonly action: string; readonly resources: readonly string[]; readonly save?: readonly string[]; readonly truncated?: true }): DecisionRequest {
156
+ const cut: { field: "resources" | "save"; index: number; length: number }[] = []
157
+ const bound = (field: "resources" | "save", list: readonly string[]) =>
158
+ list.slice(0, DECISION_REQUEST_ITEMS).map((r, index) => {
159
+ if (r.length > DECISION_REQUEST_TEXT) cut.push({ field, index, length: r.length })
160
+ return r.slice(0, DECISION_REQUEST_TEXT)
161
+ })
162
+ const resources = bound("resources", request.resources)
163
+ const save = request.save?.length ? bound("save", request.save) : undefined
164
+ return {
165
+ action: request.action.slice(0, DECISION_REQUEST_TEXT),
166
+ resources,
167
+ ...(save ? { save } : {}),
168
+ ...(request.resources.length > resources.length ? { resourcesTotal: request.resources.length } : {}),
169
+ ...(save && request.save!.length > save.length ? { savesTotal: request.save!.length } : {}),
170
+ ...(cut.length ? { cut } : {}),
171
+ ...(request.truncated ? { truncated: true as const } : {}),
172
+ }
121
173
  }
122
174
 
123
175
  /** A grant an agent requested (grant-requests.ts): approving creates it as the user. */
@@ -457,11 +509,15 @@ export class Decisions {
457
509
 
458
510
  // ---- internals ----------------------------------------------------------------------------------------
459
511
 
460
- /** Whether the session is one of the user's Concierge sessions; a failed read is no label (never fails the ask). */
512
+ /**
513
+ * Whether the session is one of the user's Concierge sessions; a failed read is no label (never fails the ask).
514
+ * (FX-07) A read that timed out (`busy`) refuses the ask instead: the label could not be checked (fail closed).
515
+ */
461
516
  private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
462
517
  try {
463
518
  return (await this.deps.concierge?.(userID, sessionID)) === true
464
- } catch {
519
+ } catch (cause) {
520
+ if (cause instanceof AgentError && cause.code === "busy") throw cause
465
521
  return false
466
522
  }
467
523
  }
@@ -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"
@@ -61,6 +68,8 @@ export interface DeliveryRegistry {
61
68
  idle(sessionID: string): boolean
62
69
  /** Higher: more recent activity. */
63
70
  recency(sessionID: string): number
71
+ /** A revert is staged in the session (Registry.revertStaged): nothing is sent to it (absent: never). */
72
+ revertStaged?(sessionID: string): boolean
64
73
  }
65
74
 
66
75
  /** Address resolution (Directory.resolve) and the primer flag (without primed/markPrimed: no primer). */
@@ -80,8 +89,8 @@ export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<Deliv
80
89
  * ask stayed unanswered by an idle recipient after every re-wake.
81
90
  */
82
91
  export interface DeliveryAlert {
83
- /** user: a session or app sent the user (@me) a message. */
84
- readonly kind: "hopCap" | "unanswered" | "user"
92
+ /** user: a session or app sent the user (@me) a message; expired: the user's message stayed pending for 24 h. */
93
+ readonly kind: "hopCap" | "unanswered" | "user" | "expired"
85
94
  /** The recipient session (user: the sending session, "" for an app). */
86
95
  readonly sessionID: string
87
96
  readonly messageID: string
@@ -113,8 +122,12 @@ export interface DeliveryDeps {
113
122
  /** Labels of a session sender (default: its @name from the directory). */
114
123
  readonly describe?: (userID: string, sessionID: string) => PartyInfo
115
124
  readonly alert?: (userID: string, alert: DeliveryAlert) => void
125
+ /** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
126
+ readonly expired?: (userID: string, message: Message) => void
116
127
  /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
117
128
  readonly limits?: Partial<MessageLimits>
129
+ /** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
130
+ readonly relayWindowMs?: number
118
131
  /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
119
132
  readonly users?: () => Iterable<string> | Promise<Iterable<string>>
120
133
  /** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
@@ -136,6 +149,11 @@ export interface SendOptions {
136
149
  readonly urgent?: boolean
137
150
  /** Asks only. */
138
151
  readonly expiresInMs?: number
152
+ /**
153
+ * Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
154
+ * larger of its own and this). Team intake passes the submitting session's depth + 1.
155
+ */
156
+ readonly chainHop?: number
139
157
  }
140
158
 
141
159
  export interface Sent {
@@ -144,6 +162,8 @@ export interface Sent {
144
162
  readonly delivery?: DeliveryState
145
163
  /** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
146
164
  readonly woke?: boolean
165
+ /** Why it was not (only when woke is false). */
166
+ readonly wakeSkipped?: WakeSkip
147
167
  }
148
168
 
149
169
  /** A reply or decline as sent, with the message it answered. */
@@ -247,7 +267,7 @@ export class Delivery {
247
267
  this.newID = deps.newID ?? newMessageID
248
268
  this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
249
269
  this.messages = new Messages({ state: deps.state, now, newID: this.newID })
250
- this.hops = new HopTracker({ state: deps.state, now })
270
+ this.hops = new HopTracker({ state: deps.state, now, ...(deps.relayWindowMs === undefined ? {} : { relayWindowMs: deps.relayWindowMs }) })
251
271
  this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
252
272
  // An explicit (re)name primes again, so the session learns its new address.
253
273
  deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
@@ -367,6 +387,11 @@ export class Delivery {
367
387
  return
368
388
  }
369
389
  for (const userID of users) {
390
+ try {
391
+ await this.expirePending(userID)
392
+ } catch (cause) {
393
+ console.error(`[hub] pending expiry failed: ${cause instanceof Error ? cause.message : String(cause)}`)
394
+ }
370
395
  try {
371
396
  await this.rewake(userID)
372
397
  } catch (cause) {
@@ -404,7 +429,7 @@ export class Delivery {
404
429
  const from = this.party(userID, actor)
405
430
  const recipient: Party = { kind: "user" }
406
431
  await this.limiter.admit(userID, from, recipient)
407
- const hop = await this.hops.next(userID, from)
432
+ const hop = await this.hops.next(userID, from, { to: recipient })
408
433
  const message = await this.messages.send(userID, {
409
434
  from,
410
435
  to: USER_ADDRESS,
@@ -428,7 +453,7 @@ export class Delivery {
428
453
  await this.deps.inbound?.admit(userID, from, sessionID, kind)
429
454
  this.gate(userID, sessionID)
430
455
  await this.limiter.admit(userID, from, recipient)
431
- const hop = await this.hops.next(userID, from)
456
+ const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
432
457
  const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
433
458
  const message = await this.messages.send(userID, {
434
459
  from,
@@ -440,15 +465,15 @@ export class Delivery {
440
465
  ...(generation === undefined ? {} : { generation }),
441
466
  ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
442
467
  })
443
- await this.hops.received(userID, recipient, hop)
468
+ await this.hops.received(userID, recipient, hop, from)
444
469
  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)) {
470
+ const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
471
+ const skipped = await this.wakeFor(userID, message, unwanted)
472
+ if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
448
473
  const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
449
474
  await this.deps.state.put(userID, REWAKE, message.id, rewake)
450
475
  }
451
- return this.deliver(userID, message, sessionID, wake, options.urgent === true)
476
+ return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
452
477
  }
453
478
 
454
479
  private schedule(): void {
@@ -555,33 +580,38 @@ export class Delivery {
555
580
  }
556
581
  if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
557
582
  if (to) await this.limiter.admit(userID, from, to)
558
- const hop = await this.hops.next(userID, from)
583
+ const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
559
584
  const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
560
585
  const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
561
586
  const { message } = answered
562
- await this.hops.received(userID, message.recipient, hop)
587
+ await this.hops.received(userID, message.recipient, hop, from)
563
588
  this.users.add(userID)
564
589
  if (message.recipient.kind !== "session") return answered
565
590
  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 }
591
+ const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
592
+ const skipped = await this.wakeFor(userID, message, unwanted)
593
+ return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
568
594
  }
569
595
 
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
596
+ /**
597
+ * Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
598
+ * the hop cap (alerting once at the cap), or no wake budget left.
599
+ */
600
+ private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
601
+ if (unwanted) return unwanted
602
+ if (message.recipient.kind !== "session") return "wake_false"
573
603
  if (overHopCap(message.hop)) {
574
604
  if (message.hop === HOP_CAP + 1) {
575
605
  this.deps.alert?.(userID, {
576
606
  kind: "hopCap",
577
607
  sessionID: message.recipient.sessionID,
578
608
  messageID: message.id,
579
- text: `Sessions messaged each other ${HOP_CAP} times without you; further messages no longer wake them`,
609
+ 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
610
  })
581
611
  }
582
- return false
612
+ return "hop_cap"
583
613
  }
584
- return this.limiter.wake(userID, message.recipient)
614
+ return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
585
615
  }
586
616
 
587
617
  /** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
@@ -619,16 +649,18 @@ export class Delivery {
619
649
  }
620
650
 
621
651
  /** 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> {
652
+ private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
653
+ const wake = skipped === undefined
654
+ const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
623
655
  // Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
624
656
  // one starts then carries them, instead of them arriving inside it.
625
657
  if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
626
658
  const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
627
- if (delivered) return { ...delivered, woke: wake }
659
+ if (delivered) return { ...delivered, ...woke }
628
660
  const at = this.now()
629
661
  const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
630
662
  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 }
663
+ return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
632
664
  }
633
665
 
634
666
  /** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
@@ -643,8 +675,13 @@ export class Delivery {
643
675
  return { message: updated, delivery: result.delivered }
644
676
  }
645
677
 
646
- /** Sends the request to each of the user's deliver-capable connections for the session until one takes it. */
678
+ /**
679
+ * Sends the request to each of the user's deliver-capable connections for the session until one takes it. Not at
680
+ * all while a revert is staged there: admitting anything commits it (decision in docs/session-view.md), so the
681
+ * message stays pending and goes in on the sessions frame that shows the revert restored or committed.
682
+ */
647
683
  private async request(userID: string, sessionID: string, req: DeliverRequest): Promise<DeliverResult | undefined> {
684
+ if (this.deps.registry.revertStaged?.(sessionID)) return undefined
648
685
  for (const connID of this.targets(userID, sessionID)) {
649
686
  let result: unknown
650
687
  try {
@@ -671,7 +708,11 @@ export class Delivery {
671
708
  this.flushStats.skipped++
672
709
  continue
673
710
  }
674
- const message = value.expiresAt > this.now() ? await this.messages.get(userID, value.messageID) : undefined
711
+ if (value.expiresAt <= this.now()) {
712
+ await this.expireEntry(userID, key, value, version)
713
+ continue
714
+ }
715
+ const message = await this.messages.get(userID, value.messageID)
675
716
  // Gone, or an ask that closed (expired) while it waited: nothing to deliver.
676
717
  if (!message || (message.kind === "ask" && message.state !== "open")) {
677
718
  await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
@@ -695,6 +736,48 @@ export class Delivery {
695
736
  }
696
737
  }
697
738
 
739
+ /** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
740
+ private async expirePending(userID: string): Promise<void> {
741
+ const t = this.now()
742
+ for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
743
+ if (value.expiresAt <= t) await this.expireEntry(userID, key, value, version)
744
+ }
745
+ }
746
+
747
+ /**
748
+ * A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
749
+ * told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
750
+ * journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
751
+ */
752
+ private async expireEntry(userID: string, key: string, value: Pending, version: number): Promise<void> {
753
+ if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
754
+ const current = await this.messages.get(userID, value.messageID)
755
+ if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
756
+ const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
757
+ const what = `${message.kind} ${message.id} to ${message.to}`
758
+ // Why it waited: a staged revert holds every delivery (request()), else the session was offline or busy.
759
+ const why = this.deps.registry.revertStaged?.(value.sessionID) ? "a revert stayed staged in its session for 24 h" : "its session stayed offline or busy for 24 h"
760
+ try {
761
+ this.deps.expired?.(userID, message)
762
+ } catch (cause) {
763
+ console.error(`[hub] pending expiry journal failed: ${cause instanceof Error ? cause.message : String(cause)}`)
764
+ }
765
+ const { from } = message
766
+ if (from.kind === "user") {
767
+ this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: ${why}` })
768
+ } else if (from.kind === "session") {
769
+ const id = this.newID()
770
+ await this.request(userID, from.sessionID, {
771
+ op: "deliver",
772
+ sessionID: from.sessionID,
773
+ messageID: id,
774
+ text: `[hub] notice · ${what} was not delivered: ${why} · ${id}`,
775
+ wake: false,
776
+ meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
777
+ })
778
+ }
779
+ }
780
+
698
781
  /** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
699
782
  private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
700
783
  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