@staix/agent-hub 0.30.0 → 0.32.0

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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
@@ -1,17 +1,19 @@
1
1
  /** Host integration for X4. Remote identities carry chat, never local control authority. */
2
- import type { Bus } from "../hub/bus.ts";
3
- import { USER, MAX_HOP, type Envelope, type PeerState } from "../hub/envelope.ts";
2
+ import { ROOM_EPOCH_ROTATED_REASON, ROOM_PROOF_UNAVAILABLE_REASON, type Bus } from "../hub/bus.ts";
3
+ import { USER, MAX_HOP, isInvocationKind, type Envelope, type PeerState } from "../hub/envelope.ts";
4
4
  import type { RoomInvocationAuthorization } from "./boundary.ts";
5
5
  import { roomInvocationHash } from "../hub/room-invocations.ts";
6
6
  import { Limiter, PROJECT_LIMITS, type LimitsConfig } from "../hub/limits.ts";
7
7
  import type { PiiVerdict } from "../hub/inference.ts";
8
8
  import type { Routing } from "../hub/routing.ts";
9
- import { roomMembers } from "./wire.ts";
9
+ import { RoomCryptoError } from "./crypto.ts";
10
+ import { roomMembers, RoomWireError } from "./wire.ts";
10
11
  import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
11
12
  import { RoomPolicy } from "./policy.ts";
12
13
  import { RemotePeer } from "./remote-peer.ts";
13
14
  import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
14
- import { RoomApplicationDeferredError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
15
+ import { RoomApplicationDeferredError, RoomNotSentError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
16
+ import { ROOM_PRESENCE_MISSES, ROOM_REFRESH_MS, roomRefreshPeriod } from "./liveness.ts";
15
17
 
16
18
  export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
17
19
  export interface RoomInvocationIngress {
@@ -19,7 +21,7 @@ export interface RoomInvocationIngress {
19
21
  current: () => boolean; claim: () => boolean;
20
22
  }
21
23
  export interface RoomChatOptions {
22
- bus: Bus; cwd: string; now?: () => number;
24
+ bus: Bus; cwd: string; now?: () => number; refreshMs?: number;
23
25
  routing: () => Routing; piiPaths: () => readonly string[];
24
26
  senderPrivate: (peer: string) => boolean | undefined;
25
27
  taskPrivate: (id: number) => boolean | undefined;
@@ -33,7 +35,7 @@ export interface RoomChatOptions {
33
35
  notice?: (value: { code: "application_capacity"; room: string; count: number } | { code: "sequence_gap"; room: string; sender: string; count: string }) => void;
34
36
  }
35
37
  export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
36
- interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; online: boolean; states: Map<string, PeerState> }
38
+ interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; rosterMembers: string; online: boolean; states: Map<string, PeerState>; observedAt: Map<string, number>; observedMembers: Set<string>; tracked?: boolean }
37
39
  interface Target { binding: Binding; member: string; peer: string }
38
40
  const ACTIVE = new Set(["dispatching", "accepted", "needs_review"]);
39
41
  function completedText(reason?: RoomReceiptReason): string {
@@ -48,6 +50,12 @@ function refusalReason(row: Pick<RoomOutgoing, "reason" | "lateReceipt">): RoomR
48
50
  const late = row.lateReceipt?.reason;
49
51
  return late && late !== "admitted" && late !== "inbox_admitted" ? late : undefined;
50
52
  }
53
+ function permanentOutboundProofFailure(error: unknown): boolean {
54
+ if (error instanceof RoomStoreError) return ["invalid", "corrupt", "unsupported", "untrusted", "state_changed"].includes(error.code);
55
+ if (error instanceof RoomCryptoError) return error.code !== "unavailable";
56
+ if (error instanceof RoomWireError) return ["invalid_wire", "untrusted", "wrong_epoch", "authentication_failed", "state_changed"].includes(error.code);
57
+ return false;
58
+ }
51
59
 
52
60
  export class RoomChat {
53
61
  private readonly policy: RoomPolicy;
@@ -60,6 +68,11 @@ export class RoomChat {
60
68
  private statusTargets = new Map<string, string[] | null>();
61
69
  private readonly remotes = new Map<string, RemotePeer>();
62
70
  private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
71
+ private readonly resuming = new Set<string>();
72
+ private readonly personHeldInvocations = new Map<string, Set<string>>();
73
+ private readonly refreshMs: number;
74
+ private presenceClockHighWater?: number;
75
+ private presenceClockInvalid = false;
63
76
  private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
64
77
  private timer?: ReturnType<typeof setTimeout>;
65
78
  private stopped = false;
@@ -69,25 +82,76 @@ export class RoomChat {
69
82
  readonly application: RoomLinkApplication;
70
83
  constructor(private readonly options: RoomChatOptions) {
71
84
  this.policy = new RoomPolicy(options.cwd);
85
+ this.refreshMs = roomRefreshPeriod(options.refreshMs ?? ROOM_REFRESH_MS);
72
86
  let since: number | null = null;
73
- try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) since = value; } catch { /* Observation failure never disables transport construction. */ }
87
+ try { const value = this.now(); if (Number.isSafeInteger(value) && value >= 0) { since = value; this.presenceClockHighWater = value; } } catch { /* Observation failure never disables transport construction. */ }
74
88
  this.metricsSince = since;
75
89
  this.application = {
76
90
  members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
77
91
  receive: (context, input) => this.receive(context, input),
78
- online: async context => { if (context.current()) { this.bind(context.store, context.name, context.link); this.bindings.get(context.name)!.online = true; this.refreshTargets(); this.refreshOutgoing(this.bindings.get(context.name)!); this.queueChanged(); } },
92
+ online: async context => { if (context.current()) {
93
+ this.bind(context.store, context.name, context.link);
94
+ const binding = this.bindings.get(context.name)!; binding.online = true;
95
+ this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
96
+ this.wakeRemotes(binding);
97
+ this.reconcileSignedOutcomes(binding);
98
+ this.reconcileMissingStoreIntents(binding);
99
+ void this.resumeActive(binding);
100
+ } },
101
+ receiptTtlMs: context => { if (!context.current()) throw new Error("room: state_changed"); return this.policy.timing().ttlMs; },
79
102
  stored: (context, input) => {
80
103
  if (!context.current()) return;
81
104
  const row = context.store.markApplicationStored(context.name, input.digest, input.seq);
82
105
  if (row) this.outcome(this.bindings.get(context.name)!, row);
106
+ const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
83
107
  },
84
- capacityChanged: context => { if (context.current()) this.capacityChanged(); },
108
+ capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
85
109
  gap: (context, input) => {
86
110
  if (context.current()) this.options.notice?.({ code: "sequence_gap", room: context.store.get(context.name)!.current.room, sender: input.sender, count: input.count });
87
111
  },
88
112
  };
89
113
  }
90
114
  private now(): number { return this.options.now?.() ?? Date.now(); }
115
+ /** Local receipt time only; remote timestamps never participate in freshness. */
116
+ private presenceNow(mutate = true): number | undefined {
117
+ let now: number;
118
+ try { now = this.now(); } catch { now = NaN; }
119
+ if (!Number.isSafeInteger(now) || now < 0 || this.presenceClockHighWater !== undefined && now < this.presenceClockHighWater) {
120
+ if (mutate && !this.presenceClockInvalid) {
121
+ for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) binding.observedAt.clear();
122
+ }
123
+ if (mutate) this.presenceClockInvalid = true;
124
+ return undefined;
125
+ }
126
+ if (mutate) {
127
+ if (this.presenceClockHighWater === undefined || now > this.presenceClockHighWater) this.presenceClockHighWater = now;
128
+ this.presenceClockInvalid = false;
129
+ }
130
+ return now;
131
+ }
132
+ private livenessTracked(binding: Binding): boolean | undefined {
133
+ return binding.tracked;
134
+ }
135
+ private memberFresh(binding: Binding, member: string, now: number | undefined): boolean {
136
+ if (this.livenessTracked(binding) === false) return true;
137
+ if (now === undefined || this.presenceClockInvalid) return false;
138
+ const seen = binding.observedAt.get(member);
139
+ return seen !== undefined && now >= seen && now - seen < this.refreshMs * ROOM_PRESENCE_MISSES;
140
+ }
141
+ private advisoryPresence(id: string, target: Target): PeerState | "unknown" {
142
+ if (this.livenessTracked(target.binding) !== false && !this.memberFresh(target.binding, target.member, this.presenceNow(false))) return "unknown";
143
+ return target.peer === USER ? "idle" : target.binding.states.get(id) ?? "offline";
144
+ }
145
+ private advisoryLiveness(target: Target): "untracked" | undefined {
146
+ return this.livenessTracked(target.binding) === false ? "untracked" : undefined;
147
+ }
148
+ private targetSendable(id: string): boolean {
149
+ const target = this.targets.get(id);
150
+ return !!target && target.binding.online && this.valid(target.binding);
151
+ }
152
+ private wakeRemotes(binding: Binding): void {
153
+ for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
154
+ }
91
155
  metrics(name: string, room?: string): RoomMetrics | null {
92
156
  const binding = this.bindings.get(name);
93
157
  if (this.stopped || !binding || this.metricsSince === null || room !== undefined && binding.room !== room) return null;
@@ -107,16 +171,38 @@ export class RoomChat {
107
171
  private valid(binding: Binding): boolean {
108
172
  return !this.stopped && this.bindings.get(binding.name) === binding && binding.store.get(binding.name)?.left === false;
109
173
  }
174
+ private currentOutgoingRow(binding: Binding, expected: RoomOutgoing): () => boolean {
175
+ const digest = binding.digest, room = binding.room;
176
+ return () => {
177
+ try {
178
+ if (!this.valid(binding) || binding.store.get(binding.name)?.current.room !== room || binding.store.get(binding.name)?.current.digest !== digest) return false;
179
+ const row = binding.store.applicationOutbox(binding.name).find(item => item.deliveryId === expected.deliveryId);
180
+ return !!row && row.application === expected.application && row.remotePeer === expected.remotePeer
181
+ && row.recipient === expected.recipient && row.peer === expected.peer && row.state === expected.state
182
+ && row.disposition === expected.disposition && row.reason === expected.reason && row.expiresAt === expected.expiresAt
183
+ && JSON.stringify(row.lateReceipt) === JSON.stringify(expected.lateReceipt);
184
+ } catch { return false; }
185
+ };
186
+ }
110
187
  bind(store: RoomStore, name: string, link: RoomLink): void {
111
188
  if (this.stopped) return;
112
189
  const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
113
- const room = state.current.room, counted = this.counts.get(name);
190
+ const room = state.current.room, rosterMembers = roomMembers(state.current).map(member => member.fingerprint).sort().join(","), counted = this.counts.get(name);
114
191
  if (counted?.room !== room && (counted || this.counts.size < 64)) this.counts.set(name, { room, outbound: 0, inbound: 0, withheld: 0 });
115
192
  const saved = this.bindings.get(name);
116
- const binding = saved?.store === store && saved.link === link && saved.room === room ? saved : { store, name, room, link, digest, online: false, states: new Map<string, PeerState>() };
117
- if (binding.digest !== digest) { binding.states.clear(); binding.online = false; binding.digest = digest; }
193
+ const binding: Binding = saved?.store === store && saved.link === link && saved.room === room ? saved : {
194
+ store, name, room, link, digest, rosterMembers, online: false, states: new Map<string, PeerState>(), observedAt: new Map<string, number>(), observedMembers: new Set<string>(),
195
+ };
196
+ const tracked = link.livenessTracked;
197
+ if (binding.digest !== digest || binding.rosterMembers !== rosterMembers
198
+ || tracked !== undefined && binding.tracked !== undefined && tracked !== binding.tracked) {
199
+ binding.states.clear(); binding.observedAt.clear(); binding.observedMembers.clear(); binding.online = false; binding.digest = digest; binding.rosterMembers = rosterMembers;
200
+ }
201
+ if (tracked !== undefined) binding.tracked = tracked;
118
202
  this.bindings.set(name, binding);
119
- this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
203
+ this.reconcileDispositions(binding);
204
+ this.refreshInvocationHolds(binding);
205
+ this.refreshOutgoing(binding); this.refreshTargets(); this.reconcileProofUnavailable(binding); this.queueChanged();
120
206
  }
121
207
  invocationIdentity(id: string, sender?: string): RoomInvocationIdentity | undefined {
122
208
  const target = this.targets.get(id), state = target?.binding.store.get(target.binding.name);
@@ -128,6 +214,14 @@ export class RoomChat {
128
214
  } };
129
215
  }
130
216
  private refreshTargets(): void {
217
+ const now = this.presenceNow();
218
+ // Consume an expired observation once. Keeping its timestamp makes every
219
+ // later queue callback revisit this branch and can re-reserve settled rows.
220
+ if (now !== undefined) for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) {
221
+ for (const [member, seenAt] of binding.observedAt) {
222
+ if (now >= seenAt && now - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES) binding.observedAt.delete(member);
223
+ }
224
+ }
131
225
  const candidates = new Map<string, Target[]>();
132
226
  for (const binding of this.bindings.values()) {
133
227
  const state = binding.store.get(binding.name);
@@ -177,22 +271,43 @@ export class RoomChat {
177
271
  const binding = selected.binding;
178
272
  if (!current()) throw new Error("room: state_changed");
179
273
  const timing = this.policy.timing(), remaining = expiresAt - this.now();
180
- if (remaining <= 0) throw new Error("room: expired");
274
+ const retained = binding.store.applicationOutbox(binding.name, true).find(row => row.deliveryId === deliveryId);
275
+ if (remaining <= 0) throw retained ? new Error("room: expired") : new RoomNotSentError();
181
276
  try {
182
277
  await binding.link.publishApplication({ v: 1, type: "chat", envelope: env }, target.member, {
183
278
  deliveryId, application: env.id, remotePeer: id, recipient: target.member, peer: target.peer,
184
- ackTimeoutMs: timing.ackTimeoutMs, ttlMs: Math.min(remaining, timing.ttlMs),
279
+ ackTimeoutMs: retained?.ackTimeoutMs ?? timing.ackTimeoutMs,
280
+ ttlMs: retained ? retained.expiresAt - retained.createdAt : Math.min(remaining, timing.ttlMs),
185
281
  }, current);
186
282
  this.count(binding, "outbound", withheld === true);
187
- } catch (error) { binding.store.quarantineOutgoing(binding.name, deliveryId); throw error; }
283
+ }
188
284
  finally { this.refreshOutgoing(binding); this.queueChanged(); }
189
- }, quarantine: deliveryId => {
285
+ }, ready: () => this.options.bus.remoteSendAllowed(remote!) && this.targetSendable(id), waiting: () => this.queueChanged(),
286
+ notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
287
+ queued: deliveryId => {
288
+ const selected = this.targets.get(id);
289
+ if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return undefined;
290
+ try { return selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
291
+ catch { return undefined; }
292
+ }, holdMissing: deliveryId => {
293
+ const selected = this.targets.get(id);
294
+ if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return false;
295
+ const current = () => {
296
+ if (!this.valid(selected.binding) || this.targets.get(id)?.binding !== selected.binding
297
+ || this.targets.get(id)?.member !== target.member || this.targets.get(id)?.peer !== target.peer) return false;
298
+ try { return !selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
299
+ catch { return false; }
300
+ };
301
+ const held = this.options.bus.holdRemoteMissingIntent(remote!, deliveryId, current, "known-not-sent");
302
+ if (held) remote!.settled(deliveryId);
303
+ return held;
304
+ }, quarantine: (deliveryId, reason) => {
190
305
  const selected = this.targets.get(id);
191
306
  const cached = [...this.outgoing.values()].filter(value => value.row.deliveryId === deliveryId && value.row.remotePeer === id && value.row.recipient === target.member).map(value => value.binding);
192
307
  const bindings = [...new Set([...(selected?.member === target.member && selected.peer === target.peer ? [selected.binding] : []), ...cached])];
193
308
  for (const binding of bindings) try {
194
309
  if (binding.store.get(binding.name)?.current.room !== room) continue;
195
- const row = binding.store.quarantineOutgoing(binding.name, deliveryId);
310
+ const row = binding.store.quarantineOutgoingPermanent(binding.name, deliveryId, reason);
196
311
  if (row && row.remotePeer === id && row.recipient === target.member) { this.outcome(binding, row); return; }
197
312
  } catch { /* A retained old binding may already be closed after an explicit rejoin. */ }
198
313
  }, now: () => this.now(),
@@ -218,9 +333,24 @@ export class RoomChat {
218
333
  else { ids.push(id); shown++; }
219
334
  }
220
335
  this.statusTargets = statusTargets;
336
+ // Reserve restored Store intents before publishing a fresh idle presence can
337
+ // make the Bus drain a later queued envelope during asynchronous proof reads.
221
338
  for (const [id, remote] of this.remotes) {
222
339
  const target = this.targets.get(id);
223
- remote.presence(target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline", target?.binding.online === true);
340
+ if (!target) continue;
341
+ const queuedIds = new Set(this.options.bus.pendingEnvelopes(id).map(env => env.id));
342
+ for (const { binding, row } of this.outgoing.values()) {
343
+ if (binding === target.binding && row.remotePeer === id && row.recipient === target.member && row.peer === target.peer
344
+ && (row.state === "dispatching" || row.state === "accepted") && !row.disposition
345
+ && !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId) && !queuedIds.has(row.application)) {
346
+ remote.retainPending(row.deliveryId);
347
+ }
348
+ }
349
+ }
350
+ for (const [id, remote] of this.remotes) {
351
+ const target = this.targets.get(id);
352
+ const state = target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline";
353
+ remote.presence(state, target?.binding.online === true);
224
354
  }
225
355
  this.options.roster?.([...this.targets.keys()]);
226
356
  }
@@ -230,24 +360,36 @@ export class RoomChat {
230
360
  && this.policy.get(binding.name).includes(peer)
231
361
  && roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
232
362
  }
233
- presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[] }): void {
363
+ presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }): void {
234
364
  const binding = this.bindings.get(name), state = store.get(name);
235
365
  if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
236
366
  const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
237
367
  if (!member) return;
368
+ if (value.restored && binding.observedMembers.has(value.member)) return;
238
369
  for (const peer of member.exposedPeers) binding.states.delete(`${peer}@${member.label}`);
239
370
  for (const peer of value.peers) if (member.exposedPeers.includes(peer.id)) binding.states.set(`${peer.id}@${member.label}`, peer.state);
371
+ const now = this.presenceNow();
372
+ if (value.restored !== true) {
373
+ binding.observedMembers.add(member.fingerprint);
374
+ if (now !== undefined) binding.observedAt.set(member.fingerprint, now);
375
+ }
240
376
  this.refreshTargets();
377
+ this.queueChanged();
241
378
  }
242
379
  linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
380
+ // stop() fences callbacks as part of ordinary shutdown; it is not a room
381
+ // trust revocation and must leave durable outbound intents resumable.
382
+ if (this.stopped) return;
243
383
  const binding = this.bindings.get(name);
244
384
  if (!binding || binding.store !== store || binding.link !== link) return;
245
- binding.online = state === "online";
385
+ // Application.online promotes readiness after membership and inbox recovery finish.
386
+ // A raw socket-online notification must not start the same recovery twice.
387
+ if (state !== "online") binding.online = false;
246
388
  // A transport blip does not revoke the last authenticated signed state.
247
389
  // A new roster digest still clears it in bind(), before target reconstruction.
248
390
  if (!this.valid(binding)) {
249
391
  try { for (const row of binding.store.applicationOutbox(name, true)) if (row.state === "dispatching" || row.state === "accepted") {
250
- const unknown = binding.store.quarantineOutgoing(name, row.deliveryId); if (unknown) this.outcome(binding, unknown);
392
+ const unknown = binding.store.quarantineOutgoingPermanent(name, row.deliveryId, "boundary_refused"); if (unknown) this.outcome(binding, unknown);
251
393
  } } catch { for (const [key, row] of this.outgoing) if (row.binding === binding) this.outgoing.delete(key); }
252
394
  }
253
395
  this.refreshTargets();
@@ -262,11 +404,120 @@ export class RoomChat {
262
404
  return [...new Set([...this.outgoing.values()].filter(({ binding, row }) => binding.store.get(binding.name)?.current.room === room && ACTIVE.has(row.state) && !row.disposition).map(({ row }) => row.application))];
263
405
  }
264
406
  private outcome(binding: Binding, row: RoomOutgoing): void {
407
+ // Refresh this snapshot before settlement callbacks synchronously re-enter
408
+ // queueChanged through Bus.onQueues.
409
+ this.refreshOutgoing(binding);
265
410
  const remote = this.remotes.get(row.remotePeer);
266
- if (row.lateReceipt) this.options.bus.noteLateRemoteReceipt(row.remotePeer, row.deliveryId, row.lateReceipt.outcome);
411
+ if (row.state === "needs_review" && !row.disposition) {
412
+ const journalRow = this.options.bus.queueShow(row.deliveryId);
413
+ if (journalRow?.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.deliveryId)) {
414
+ const held = this.personHeldInvocations.get(binding.name) ?? new Set<string>();
415
+ held.add(row.deliveryId); this.personHeldInvocations.set(binding.name, held);
416
+ }
417
+ }
418
+ if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) this.settleStoredOutcome(binding, remote, row);
419
+ else if (row.state === "needs_review" && !row.disposition && remote) {
420
+ if (!this.holdStoreNeedsReview(binding, row, remote)) remote.receipt({ id: row.deliveryId, state: "needs_review", reason: "room acknowledgement expired or outcome uncertain" });
421
+ }
267
422
  else if (!row.disposition) remote?.receipt({ id: row.deliveryId, state: row.state === "dispatching" ? "needs_review" : row.state, reason: row.state === "completed" ? `room ${completedText(row.reason)}` : row.state === "failed_safe" ? roomReceiptReasonText(row.reason ?? "invalid_message") : row.state === "accepted" ? "room broker accepted ciphertext" : "room acknowledgement expired or outcome uncertain" });
268
423
  this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
269
424
  }
425
+ private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
426
+ if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
427
+ this.refreshOutgoing(binding);
428
+ const outcome = row.lateReceipt?.outcome ?? row.state;
429
+ const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
430
+ const journalRow = this.options.bus.queueShow(row.deliveryId);
431
+ if (journalRow?.state === "needs_review" && journalRow.originals.some(isInvocationKind)) {
432
+ if (!this.options.bus.remoteDisposition(row.deliveryId)) this.options.bus.noteLateRemoteReceipt(row.remotePeer, row.deliveryId, outcome);
433
+ return false;
434
+ }
435
+ const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
436
+ const targetCurrent = target?.binding === binding && target.member === row.recipient && target.peer === row.peer;
437
+ const proofCurrent = () => this.valid(binding) && binding.store.get(binding.name)?.current.room === binding.room
438
+ && binding.store.get(binding.name)?.current.digest === state?.current.digest
439
+ && binding.store.applicationOutbox(binding.name).some(saved => saved.deliveryId === row.deliveryId
440
+ && saved.remotePeer === row.remotePeer && saved.recipient === row.recipient && saved.peer === row.peer
441
+ && saved.state === row.state && !saved.disposition);
442
+ if (!targetCurrent || !remote) {
443
+ const invalidated = () => proofCurrent() && (() => {
444
+ const next = this.targets.get(row.remotePeer);
445
+ return !next || next.binding !== binding || next.member !== row.recipient || next.peer !== row.peer;
446
+ })();
447
+ return this.options.bus.holdInvalidatedRemoteOutcome(row.remotePeer, row.deliveryId, binding.room, row.recipient, invalidated);
448
+ }
449
+ const current = () => this.valid(binding) && target?.binding === binding && target.member === row.recipient
450
+ && target.peer === row.peer && state?.current.room === binding.room
451
+ && binding.store.get(binding.name)?.current.digest === state?.current.digest;
452
+ const settled = this.options.bus.settleRemoteReceipt(remote, row.deliveryId, outcome,
453
+ outcome === "completed" ? `room ${completedText(reason)}` : `room ${roomReceiptReasonText(reason)}`, current);
454
+ if (settled) remote.settled(row.deliveryId);
455
+ return settled;
456
+ }
457
+ /** Reconcile a signed store commit that may have preceded a process crash or Bus CAS. */
458
+ private reconcileSignedOutcomes(binding: Binding): void {
459
+ if (!binding.online || !this.valid(binding)) return;
460
+ try {
461
+ for (const row of binding.store.applicationOutbox(binding.name)) {
462
+ if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) {
463
+ const remote = this.remotes.get(row.remotePeer);
464
+ this.settleStoredOutcome(binding, remote, row);
465
+ }
466
+ }
467
+ } catch { /* The next authenticated online edge retries the signed outcome reconciliation. */ }
468
+ }
469
+ /** Hold restart-era Bus rows only after signed Store outcomes have had their reconciliation chance. */
470
+ private reconcileMissingStoreIntents(binding: Binding): void {
471
+ if (!binding.online || !this.valid(binding)) return;
472
+ let saved: RoomOutgoing[];
473
+ try { saved = binding.store.applicationOutbox(binding.name); }
474
+ catch { return; }
475
+ const known = new Set(saved.map(row => row.deliveryId));
476
+ for (const [id, target] of this.targets) {
477
+ if (target.binding !== binding) continue;
478
+ const remote = this.remotes.get(id);
479
+ if (!remote) continue;
480
+ const digest = binding.digest;
481
+ for (const row of this.options.bus.queueList(id, true).slice(0, 200)) {
482
+ if ((row.state !== "dispatching" && row.state !== "accepted") || known.has(row.id)) continue;
483
+ const current = () => this.valid(binding) && binding.store.get(binding.name)?.current.digest === digest
484
+ && this.targets.get(id)?.binding === binding && this.targets.get(id)?.member === target.member
485
+ && this.targets.get(id)?.peer === target.peer && binding.store.get(binding.name)?.current.room === binding.room
486
+ && !binding.store.applicationOutbox(binding.name).some(savedRow => savedRow.deliveryId === row.id);
487
+ this.options.bus.holdRemoteMissingIntent(remote, row.id, current);
488
+ }
489
+ }
490
+ }
491
+ private holdStoreNeedsReview(binding: Binding, row: RoomOutgoing, remote: RemotePeer): boolean {
492
+ const journal = this.options.bus.queueShow(row.deliveryId);
493
+ if (!journal || row.disposition || (journal.state !== "dispatching" && journal.state !== "accepted" && journal.state !== "needs_review")) return false;
494
+ const current = this.currentOutgoingRow(binding, row);
495
+ if (journal.originals.some(isInvocationKind)) return false;
496
+ const held = this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable");
497
+ if (held) remote.settled(row.deliveryId);
498
+ return held;
499
+ }
500
+ /** Reconcile Store TTL/permanent holds and Bus-first proof holds before any resume starts. */
501
+ private reconcileProofUnavailable(binding: Binding): void {
502
+ if (!this.valid(binding)) return;
503
+ let rows: RoomOutgoing[];
504
+ try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
505
+ this.refreshOutgoing(binding);
506
+ for (const row of rows) {
507
+ if (row.disposition) continue;
508
+ const remote = this.remotes.get(row.remotePeer);
509
+ const journal = this.options.bus.queueShow(row.deliveryId);
510
+ if (!remote || !journal) continue;
511
+ const heldProof = journal.state === "needs_review" && journal.reason === `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
512
+ if (heldProof && (row.state === "dispatching" || row.state === "accepted")) {
513
+ const current = this.currentOutgoingRow(binding, row);
514
+ if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
515
+ const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
516
+ if (quarantined) { this.refreshOutgoing(binding); remote.settled(row.deliveryId); }
517
+ } else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
518
+ }
519
+ this.refreshOutgoing(binding);
520
+ }
270
521
  private rate(from: string, member: string, target: string, body: string): boolean {
271
522
  const config = this.options.limits?.() ?? PROJECT_LIMITS, key = JSON.stringify(config);
272
523
  if (key !== this.limitKey) { this.limitKey = key; this.peerLimiter = new Limiter(config, () => this.now()); }
@@ -284,6 +535,7 @@ export class RoomChat {
284
535
  if (!context.current() || this.stopped) throw new Error("room: state_changed");
285
536
  const binding = this.bindings.get(context.name);
286
537
  if (!binding || binding.store !== context.store || binding.link !== context.link) throw new Error("room: unavailable");
538
+ if (!this.reconcileDispositions(binding)) throw new RoomApplicationDeferredError("journal_unavailable");
287
539
  const saved = context.store.applicationReceipt(context.name, input.digest);
288
540
  if (saved) { await context.link.publishApplication(saved, input.sender, undefined, context.current); return; }
289
541
  if (input.message?.type === "receipt") {
@@ -363,29 +615,164 @@ export class RoomChat {
363
615
  if (receipt) await context.link.publishApplication(receipt, input.sender, undefined, context.current);
364
616
  this.options.changed?.();
365
617
  }
618
+ private refreshInvocationHolds(binding: Binding): void {
619
+ const room = binding.store.get(binding.name);
620
+ if (!room || !this.valid(binding)) return;
621
+ const held = new Set<string>();
622
+ try {
623
+ for (const member of roomMembers(room.current)) {
624
+ if (member.fingerprint === binding.store.identity.public.fingerprint) continue;
625
+ for (const peer of new Set([...member.exposedPeers, USER])) {
626
+ const id = `${peer}@${member.label}`;
627
+ for (const row of this.options.bus.queueList(id, true)) {
628
+ if (row.state === "needs_review" && row.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.id)) held.add(row.id);
629
+ }
630
+ }
631
+ }
632
+ } catch { /* Unknown Bus state never grants a proof resume by itself. */ }
633
+ this.personHeldInvocations.set(binding.name, held);
634
+ }
366
635
  queueChanged(): void {
367
636
  if (this.timer) clearTimeout(this.timer);
368
637
  this.timer = undefined; if (this.stopped || !this.bindings.size) return;
369
638
  let next = this.options.bus.nextRemoteExpiry();
370
- for (const { row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
371
- const due = Math.min(row.expiresAt, row.ackDeadline ?? Infinity); next = next === undefined ? due : Math.min(next, due);
639
+ const presenceNow = this.presenceNow();
640
+ if (presenceNow !== undefined) {
641
+ if ([...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
642
+ presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
643
+ for (const binding of this.bindings.values()) if (this.livenessTracked(binding) !== false) for (const seenAt of binding.observedAt.values()) {
644
+ const staleAt = seenAt + this.refreshMs * ROOM_PRESENCE_MISSES;
645
+ if (staleAt <= presenceNow) continue;
646
+ next = next === undefined ? staleAt : Math.min(next, staleAt);
647
+ }
648
+ }
649
+ for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
650
+ const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
372
651
  }
373
652
  if (next === undefined) return;
374
653
  this.timer = setTimeout(() => { this.timer = undefined; try { this.tick(); } catch { this.stop(); } }, Math.max(1, Math.min(2_147_483_647, next - this.now())));
375
654
  this.timer.unref?.();
655
+ this.resumeReadyIntents();
656
+ }
657
+ private resumeReadyIntents(): void {
658
+ if (this.stopped) return;
659
+ for (const binding of this.bindings.values()) {
660
+ if (!binding.online || !this.valid(binding)) continue;
661
+ try {
662
+ for (const row of binding.store.applicationOutbox(binding.name, true)) {
663
+ const remote = this.remotes.get(row.remotePeer);
664
+ if (remote?.readyToResume(row.deliveryId)) this.launchResume(binding, row);
665
+ }
666
+ } catch { /* An actual future queue, receipt or online event retries the scan. */ }
667
+ }
376
668
  }
377
669
  tick(): void {
378
670
  if (this.stopped) return;
671
+ const presenceNow = this.presenceNow();
672
+ if (presenceNow === undefined || [...this.bindings.values()].some(binding => this.livenessTracked(binding) !== false && [...binding.observedAt.values()].some(seenAt =>
673
+ presenceNow - seenAt >= this.refreshMs * ROOM_PRESENCE_MISSES))) this.refreshTargets();
379
674
  this.options.bus.expireRemoteQueues(this.now());
380
675
  for (const binding of this.bindings.values()) if (this.valid(binding)) {
381
676
  try {
382
677
  for (const row of binding.store.expireApplications(binding.name)) this.outcome(binding, row);
678
+ if (binding.online) {
679
+ const due = binding.store.dueApplicationRetries(binding.name);
680
+ this.refreshOutgoing(binding);
681
+ for (const row of due) this.launchResume(binding, row);
682
+ }
383
683
  this.reconcileDispositions(binding);
384
684
  }
385
685
  catch { binding.online = false; for (const [key, entry] of this.outgoing) if (entry.binding === binding) this.outgoing.delete(key); }
386
686
  }
387
687
  void this.retryPending(); this.queueChanged();
388
688
  }
689
+ private async resumeActive(binding: Binding): Promise<void> {
690
+ if (!binding.online || !this.valid(binding)) return;
691
+ try {
692
+ const active = binding.store.applicationOutbox(binding.name, true).filter(row => (row.state === "dispatching" || row.state === "accepted")
693
+ && !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId));
694
+ for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
695
+ for (const row of active) this.launchResume(binding, row);
696
+ } catch { /* The next authenticated online edge will retry durable rows. */ }
697
+ }
698
+ private launchResume(binding: Binding, row: RoomOutgoing): void {
699
+ const key = `${binding.name}:${row.deliveryId}`;
700
+ if (this.resuming.has(key)) return;
701
+ const remote = this.remotes.get(row.remotePeer);
702
+ if (!remote) return;
703
+ this.resuming.add(key);
704
+ void this.resumeApplication(binding, row, remote).catch(() => { remote.deferResume(row.deliveryId); })
705
+ .finally(() => { this.resuming.delete(key); try { this.queueChanged(); } catch { /* a later real event or ACK deadline retries */ } });
706
+ }
707
+ private storeProofCurrent(binding: Binding, row: RoomOutgoing, remote: RemotePeer, target: Target | undefined, digest: string | undefined): boolean {
708
+ try {
709
+ if (!target || !digest || !this.valid(binding) || binding.store.get(binding.name)?.current.room !== binding.room
710
+ || binding.store.get(binding.name)?.current.digest !== digest || this.targets.get(row.remotePeer)?.binding !== binding
711
+ || this.targets.get(row.remotePeer)?.member !== row.recipient || this.targets.get(row.remotePeer)?.peer !== row.peer
712
+ || !remote.isPendingHead(row.deliveryId) || row.disposition || row.expiresAt <= this.now()) return false;
713
+ return binding.store.applicationOutbox(binding.name, true).some(saved => saved.deliveryId === row.deliveryId
714
+ && saved.application === row.application && saved.remotePeer === row.remotePeer && saved.recipient === row.recipient
715
+ && saved.peer === row.peer && (saved.state === "dispatching" || saved.state === "accepted") && !saved.disposition);
716
+ } catch { return false; }
717
+ }
718
+ private holdUnavailableProof(binding: Binding, row: RoomOutgoing, remote: RemotePeer, current: () => boolean): boolean {
719
+ const busRow = this.options.bus.queueShow(row.deliveryId);
720
+ if (busRow?.originals.some(isInvocationKind)) { remote.deferResume(row.deliveryId); return false; }
721
+ if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) { remote.deferResume(row.deliveryId); return false; }
722
+ try { binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current); }
723
+ catch { /* Bus hold is durable; startup reconciliation can finish the Store side. */ }
724
+ remote.settled(row.deliveryId);
725
+ this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
726
+ return true;
727
+ }
728
+ private async resumeApplication(binding: Binding, row: RoomOutgoing, remote: RemotePeer): Promise<void> {
729
+ if (this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return;
730
+ if (!remote || !remote.isPendingHead(row.deliveryId)) return;
731
+ const heldRow = this.options.bus.queueShow(row.deliveryId);
732
+ const original = this.options.bus.remoteDeliveryEnvelope(remote, row.deliveryId);
733
+ const proofHoldReason = `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
734
+ if (heldRow?.state === "needs_review" && heldRow.reason === proofHoldReason && original) {
735
+ if (!row.disposition && (row.state === "dispatching" || row.state === "accepted")) {
736
+ const digest = binding.store.get(binding.name)?.current.digest;
737
+ const target = this.targets.get(row.remotePeer);
738
+ const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
739
+ const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", proofCurrent);
740
+ if (quarantined) { remote.settled(row.deliveryId); this.refreshOutgoing(binding); this.queueChanged(); }
741
+ else remote.deferResume(row.deliveryId);
742
+ }
743
+ return;
744
+ }
745
+ if (!original || !binding.online || !this.valid(binding) || row.disposition || row.expiresAt <= this.now()) { remote.deferResume(row.deliveryId); return; }
746
+ const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
747
+ const digest = state?.current.digest;
748
+ const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
749
+ if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
750
+ const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
751
+ if (!ready()) { remote.waitForReady(row.deliveryId); return; }
752
+ let readinessLost = false;
753
+ const current = () => {
754
+ const allowed = ready();
755
+ if (!allowed) readinessLost = true;
756
+ return allowed && proofCurrent();
757
+ };
758
+ let saved: RoomMessage | undefined;
759
+ try { saved = await binding.store.outgoingMessage(binding.name, row.application, current); }
760
+ catch (error) {
761
+ if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
762
+ if (readinessLost || !ready()) { remote.waitForReady(row.deliveryId); return; }
763
+ if (permanentOutboundProofFailure(error)) { this.holdUnavailableProof(binding, row, remote, proofCurrent); return; }
764
+ remote.deferResume(row.deliveryId); return;
765
+ }
766
+ if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
767
+ if (!ready()) { remote.waitForReady(row.deliveryId); return; }
768
+ if (!saved || saved.type !== "chat" || saved.envelope.id !== row.application || saved.envelope.to !== row.peer) {
769
+ this.holdUnavailableProof(binding, row, remote, proofCurrent); return;
770
+ }
771
+ const resumeState = row.state === "accepted" ? "accepted" : "dispatching";
772
+ if (!this.options.bus.resumeRemotePending(remote, row.deliveryId, resumeState, current)) return;
773
+ await remote.resume(original, row.deliveryId, row.expiresAt, saved.envelope);
774
+ this.refreshOutgoing(binding); this.queueChanged();
775
+ }
389
776
  /** Global byte capacity can be freed by another room. This is event-coalesced, never polled. */
390
777
  private capacityChanged(): void {
391
778
  if (this.stopped || this.capacityRefreshPending) return;
@@ -407,19 +794,36 @@ export class RoomChat {
407
794
  } catch { /* A completed person disposition must not fail because another room is unavailable. */ }
408
795
  await Promise.allSettled(retries); this.capacityChanged();
409
796
  }
410
- private reconcileDispositions(binding: Binding): void {
411
- for (const row of binding.store.applicationOutbox(binding.name, true)) if (row.state === "needs_review" && !row.disposition) {
797
+ private reconcileDispositions(binding: Binding): boolean {
798
+ let complete = true;
799
+ const settled: Array<{ peer: string; deliveryId: string }> = [];
800
+ for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
412
801
  const action = this.options.bus.remoteDisposition(row.deliveryId);
413
- if (action) binding.store.disposeApplication(binding.name, row.deliveryId, action, () => !this.stopped);
802
+ if (action) try {
803
+ const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
804
+ && binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
805
+ if (disposed?.disposition === action) settled.push({ peer: row.remotePeer, deliveryId: row.deliveryId });
806
+ }
807
+ catch { complete = false; }
414
808
  }
415
809
  this.refreshOutgoing(binding);
810
+ for (const row of settled) this.remotes.get(row.peer)?.settled(row.deliveryId);
811
+ return complete;
416
812
  }
417
813
  disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
418
814
  if (this.options.bus.remoteDisposition(deliveryId) !== action) return;
419
815
  for (const binding of this.bindings.values()) {
420
- const row = binding.store.applicationOutbox(binding.name, true).find(row => row.deliveryId === deliveryId && row.remotePeer === peer);
816
+ const row = binding.store.applicationOutbox(binding.name).find(row => row.deliveryId === deliveryId && row.remotePeer === peer);
421
817
  if (!row) continue;
422
- binding.store.disposeApplication(binding.name, deliveryId, action, () => !this.stopped);
818
+ const room = binding.room, digest = binding.digest;
819
+ const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
820
+ && binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
821
+ && this.options.bus.remoteDisposition(deliveryId) === action);
822
+ if (disposed?.disposition === action) {
823
+ this.refreshOutgoing(binding);
824
+ this.remotes.get(peer)?.settled(deliveryId);
825
+ }
826
+ this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
423
827
  this.refreshOutgoing(binding); break;
424
828
  }
425
829
  this.refreshTargets(); this.queueChanged(); void this.retryPending();
@@ -440,20 +844,56 @@ export class RoomChat {
440
844
  const row = this.evidenceCache.get(deliveryId); if (!row || row.peer !== peer) return undefined;
441
845
  const { peer: _peer, ...view } = row; return view;
442
846
  }
847
+ /** Hold only the sealed intents captured by the person's committed epoch change. */
848
+ epochRotated(store: RoomStore, name: string, previousDigest: string, expected: readonly RoomOutgoing[]): boolean {
849
+ const state = store.get(name);
850
+ if (this.stopped || !state || state.left || state.current.digest === previousDigest) return false;
851
+ const binding = this.bindings.get(name);
852
+ if (binding?.store === store && binding.room === state.current.room) {
853
+ // Releasing an old FIFO head must not drain queued work using old presence.
854
+ binding.online = false; binding.states.clear(); binding.observedAt.clear();
855
+ for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.presence("offline", false);
856
+ }
857
+ let complete = true;
858
+ for (const before of expected) {
859
+ const current = () => {
860
+ const room = store.get(name), row = store.applicationOutbox(name).find(row => row.deliveryId === before.deliveryId);
861
+ return !this.stopped && !!room && !room.left && room.current.digest === state.current.digest
862
+ && !!row && row.application === before.application && row.remotePeer === before.remotePeer && row.recipient === before.recipient
863
+ && row.state === "needs_review" && row.reason === "boundary_refused" && !row.disposition;
864
+ };
865
+ if (!current()) { complete = false; continue; }
866
+ if (!this.options.bus.holdRemoteEpochRotated(before.remotePeer, before.deliveryId, state.current.room, before.recipient, current)) {
867
+ complete = false; continue;
868
+ }
869
+ const remote = this.remotes.get(before.remotePeer);
870
+ if (remote?.remoteRoom === state.current.room && remote.remoteMember === before.recipient) remote.settled(before.deliveryId);
871
+ }
872
+ this.evidenceCache = undefined;
873
+ return complete;
874
+ }
443
875
  rows(name: string): Record<string, unknown>[] {
444
876
  const binding = this.bindings.get(name); if (!binding) return [];
445
877
  const incoming = binding.store.applicationStatus(name);
446
- return [...binding.store.applicationOutbox(name).sort((a, b) => Number(!ACTIVE.has(a.state) || !!a.disposition) - Number(!ACTIVE.has(b.state) || !!b.disposition) || b.createdAt - a.createdAt).slice(0, 200).map(row => ({ direction: "outbound", id: row.deliveryId, application: row.application, peer: row.remotePeer, state: row.state === "completed" ? completedText(row.reason) : row.state, ...(row.disposition ? { disposition: row.disposition } : {}), ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}), ...(refusalReason(row) ? { refusalReason: refusalReason(row) } : {}) })),
878
+ const outgoing = binding.store.applicationOutbox(name).sort((a, b) => Number(!ACTIVE.has(a.state) || !!a.disposition) - Number(!ACTIVE.has(b.state) || !!b.disposition) || b.createdAt - a.createdAt).slice(0, 200), epochHolds = new Set<string>();
879
+ for (const peer of new Set(outgoing.map(row => row.remotePeer))) {
880
+ for (const row of this.options.bus.queueList(peer, true)) if (row.state === "needs_review" && row.reason === `${ROOM_EPOCH_ROTATED_REASON} ${row.id}`) epochHolds.add(row.id);
881
+ }
882
+ return [...outgoing.map(row => ({ direction: "outbound", id: row.deliveryId, application: row.application, peer: row.remotePeer, state: row.state === "completed" ? completedText(row.reason) : row.state, ...(row.disposition ? { disposition: row.disposition } : {}), ...(row.lateReceipt ? { lateReceipt: lateReceiptText(row.lateReceipt) } : {}), ...(epochHolds.has(row.deliveryId) ? { holdReason: "epoch rotated" } : refusalReason(row) ? { refusalReason: refusalReason(row) } : {}) })),
447
883
  ...incoming.rows.map(row => ({ direction: "inbound", id: row.digest, application: row.application, state: row.state === "disposed" ? `disposed (${row.disposition}, by person)` : row.state, peer: row.peer, ...(row.disposedAt ? { disposedAt: row.disposedAt } : {}) })),
448
884
  { direction: "inbound", pending: incoming.pending, needsReview: incoming.needsReview, truncated: incoming.truncated }];
449
885
  }
450
- peers(name: string): { id: string; state: PeerState }[] {
451
- return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER).map(([id]) => ({ id, state: this.remotes.get(id)?.state ?? "offline" })).sort((a, b) => a.id.localeCompare(b.id));
886
+ peers(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] {
887
+ return [...this.targets].filter(([, target]) => target.binding.name === name && target.peer !== USER)
888
+ .map(([id, target]) => ({ id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) })).sort((a, b) => a.id.localeCompare(b.id));
452
889
  }
453
890
  /** A bounded status list, separate from the explicit detail query above. */
454
- peersForStatus(name: string): { id: string; state: PeerState }[] | null {
891
+ peersForStatus(name: string): { id: string; state: PeerState; presence?: PeerState | "unknown"; liveness?: "untracked" }[] | null {
455
892
  const ids = this.statusTargets.get(name);
456
- return ids ? ids.map(id => ({ id, state: this.remotes.get(id)?.state ?? "offline" })) : null;
893
+ return ids ? ids.map(id => {
894
+ const target = this.targets.get(id)!;
895
+ return { id, state: this.remotes.get(id)?.state ?? "offline", presence: this.advisoryPresence(id, target), ...(this.advisoryLiveness(target) ? { liveness: "untracked" as const } : {}) };
896
+ }) : null;
457
897
  }
458
898
  stop(): void {
459
899
  if (this.stopped) return; this.stopped = true;