@staix/agent-hub 0.30.0 → 0.31.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 (125) hide show
  1. package/CHANGELOG.md +39 -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 +9 -4
  7. package/docs/agent-notes/daemon.md +15 -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 +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -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 +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
@@ -1,17 +1,18 @@
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_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";
15
16
 
16
17
  export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
17
18
  export interface RoomInvocationIngress {
@@ -48,6 +49,12 @@ function refusalReason(row: Pick<RoomOutgoing, "reason" | "lateReceipt">): RoomR
48
49
  const late = row.lateReceipt?.reason;
49
50
  return late && late !== "admitted" && late !== "inbox_admitted" ? late : undefined;
50
51
  }
52
+ function permanentOutboundProofFailure(error: unknown): boolean {
53
+ if (error instanceof RoomStoreError) return ["invalid", "corrupt", "unsupported", "untrusted", "state_changed"].includes(error.code);
54
+ if (error instanceof RoomCryptoError) return error.code !== "unavailable";
55
+ if (error instanceof RoomWireError) return ["invalid_wire", "untrusted", "wrong_epoch", "authentication_failed", "state_changed"].includes(error.code);
56
+ return false;
57
+ }
51
58
 
52
59
  export class RoomChat {
53
60
  private readonly policy: RoomPolicy;
@@ -60,6 +67,8 @@ export class RoomChat {
60
67
  private statusTargets = new Map<string, string[] | null>();
61
68
  private readonly remotes = new Map<string, RemotePeer>();
62
69
  private readonly outgoing = new Map<string, { binding: Binding; row: RoomOutgoing }>();
70
+ private readonly resuming = new Set<string>();
71
+ private readonly personHeldInvocations = new Map<string, Set<string>>();
63
72
  private evidenceCache?: Map<string, { peer: string; roomState: string; disposition?: string; lateReceipt?: string; refusalReason?: string }>;
64
73
  private timer?: ReturnType<typeof setTimeout>;
65
74
  private stopped = false;
@@ -75,19 +84,32 @@ export class RoomChat {
75
84
  this.application = {
76
85
  members: context => { if (context.current()) this.bind(context.store, context.name, context.link); },
77
86
  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(); } },
87
+ online: async context => { if (context.current()) {
88
+ this.bind(context.store, context.name, context.link);
89
+ const binding = this.bindings.get(context.name)!; binding.online = true;
90
+ this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
91
+ this.wakeRemotes(binding);
92
+ this.reconcileSignedOutcomes(binding);
93
+ this.reconcileMissingStoreIntents(binding);
94
+ void this.resumeActive(binding);
95
+ } },
96
+ receiptTtlMs: context => { if (!context.current()) throw new Error("room: state_changed"); return this.policy.timing().ttlMs; },
79
97
  stored: (context, input) => {
80
98
  if (!context.current()) return;
81
99
  const row = context.store.markApplicationStored(context.name, input.digest, input.seq);
82
100
  if (row) this.outcome(this.bindings.get(context.name)!, row);
101
+ const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
83
102
  },
84
- capacityChanged: context => { if (context.current()) this.capacityChanged(); },
103
+ capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
85
104
  gap: (context, input) => {
86
105
  if (context.current()) this.options.notice?.({ code: "sequence_gap", room: context.store.get(context.name)!.current.room, sender: input.sender, count: input.count });
87
106
  },
88
107
  };
89
108
  }
90
109
  private now(): number { return this.options.now?.() ?? Date.now(); }
110
+ private wakeRemotes(binding: Binding): void {
111
+ for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
112
+ }
91
113
  metrics(name: string, room?: string): RoomMetrics | null {
92
114
  const binding = this.bindings.get(name);
93
115
  if (this.stopped || !binding || this.metricsSince === null || room !== undefined && binding.room !== room) return null;
@@ -107,6 +129,19 @@ export class RoomChat {
107
129
  private valid(binding: Binding): boolean {
108
130
  return !this.stopped && this.bindings.get(binding.name) === binding && binding.store.get(binding.name)?.left === false;
109
131
  }
132
+ private currentOutgoingRow(binding: Binding, expected: RoomOutgoing): () => boolean {
133
+ const digest = binding.digest, room = binding.room;
134
+ return () => {
135
+ try {
136
+ if (!this.valid(binding) || binding.store.get(binding.name)?.current.room !== room || binding.store.get(binding.name)?.current.digest !== digest) return false;
137
+ const row = binding.store.applicationOutbox(binding.name).find(item => item.deliveryId === expected.deliveryId);
138
+ return !!row && row.application === expected.application && row.remotePeer === expected.remotePeer
139
+ && row.recipient === expected.recipient && row.peer === expected.peer && row.state === expected.state
140
+ && row.disposition === expected.disposition && row.reason === expected.reason && row.expiresAt === expected.expiresAt
141
+ && JSON.stringify(row.lateReceipt) === JSON.stringify(expected.lateReceipt);
142
+ } catch { return false; }
143
+ };
144
+ }
110
145
  bind(store: RoomStore, name: string, link: RoomLink): void {
111
146
  if (this.stopped) return;
112
147
  const state = store.get(name), digest = state?.current.digest; if (!digest || !state) return;
@@ -116,7 +151,9 @@ export class RoomChat {
116
151
  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
152
  if (binding.digest !== digest) { binding.states.clear(); binding.online = false; binding.digest = digest; }
118
153
  this.bindings.set(name, binding);
119
- this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
154
+ this.reconcileDispositions(binding);
155
+ this.refreshInvocationHolds(binding);
156
+ this.refreshOutgoing(binding); this.refreshTargets(); this.reconcileProofUnavailable(binding); this.queueChanged();
120
157
  }
121
158
  invocationIdentity(id: string, sender?: string): RoomInvocationIdentity | undefined {
122
159
  const target = this.targets.get(id), state = target?.binding.store.get(target.binding.name);
@@ -177,22 +214,43 @@ export class RoomChat {
177
214
  const binding = selected.binding;
178
215
  if (!current()) throw new Error("room: state_changed");
179
216
  const timing = this.policy.timing(), remaining = expiresAt - this.now();
180
- if (remaining <= 0) throw new Error("room: expired");
217
+ const retained = binding.store.applicationOutbox(binding.name, true).find(row => row.deliveryId === deliveryId);
218
+ if (remaining <= 0) throw retained ? new Error("room: expired") : new RoomNotSentError();
181
219
  try {
182
220
  await binding.link.publishApplication({ v: 1, type: "chat", envelope: env }, target.member, {
183
221
  deliveryId, application: env.id, remotePeer: id, recipient: target.member, peer: target.peer,
184
- ackTimeoutMs: timing.ackTimeoutMs, ttlMs: Math.min(remaining, timing.ttlMs),
222
+ ackTimeoutMs: retained?.ackTimeoutMs ?? timing.ackTimeoutMs,
223
+ ttlMs: retained ? retained.expiresAt - retained.createdAt : Math.min(remaining, timing.ttlMs),
185
224
  }, current);
186
225
  this.count(binding, "outbound", withheld === true);
187
- } catch (error) { binding.store.quarantineOutgoing(binding.name, deliveryId); throw error; }
226
+ }
188
227
  finally { this.refreshOutgoing(binding); this.queueChanged(); }
189
- }, quarantine: deliveryId => {
228
+ }, ready: () => this.options.bus.remoteSendAllowed(remote!), waiting: () => this.queueChanged(),
229
+ notSent: deliveryId => this.options.bus.requeueRemoteNotSent(remote!, deliveryId),
230
+ queued: deliveryId => {
231
+ const selected = this.targets.get(id);
232
+ if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return undefined;
233
+ try { return selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
234
+ catch { return undefined; }
235
+ }, holdMissing: deliveryId => {
236
+ const selected = this.targets.get(id);
237
+ if (!selected || selected.member !== target.member || selected.peer !== target.peer || selected.binding.store.get(selected.binding.name)?.current.room !== room) return false;
238
+ const current = () => {
239
+ if (!this.valid(selected.binding) || this.targets.get(id)?.binding !== selected.binding
240
+ || this.targets.get(id)?.member !== target.member || this.targets.get(id)?.peer !== target.peer) return false;
241
+ try { return !selected.binding.store.applicationOutbox(selected.binding.name).some(row => row.deliveryId === deliveryId); }
242
+ catch { return false; }
243
+ };
244
+ const held = this.options.bus.holdRemoteMissingIntent(remote!, deliveryId, current, "known-not-sent");
245
+ if (held) remote!.settled(deliveryId);
246
+ return held;
247
+ }, quarantine: (deliveryId, reason) => {
190
248
  const selected = this.targets.get(id);
191
249
  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
250
  const bindings = [...new Set([...(selected?.member === target.member && selected.peer === target.peer ? [selected.binding] : []), ...cached])];
193
251
  for (const binding of bindings) try {
194
252
  if (binding.store.get(binding.name)?.current.room !== room) continue;
195
- const row = binding.store.quarantineOutgoing(binding.name, deliveryId);
253
+ const row = binding.store.quarantineOutgoingPermanent(binding.name, deliveryId, reason);
196
254
  if (row && row.remotePeer === id && row.recipient === target.member) { this.outcome(binding, row); return; }
197
255
  } catch { /* A retained old binding may already be closed after an explicit rejoin. */ }
198
256
  }, now: () => this.now(),
@@ -218,6 +276,20 @@ export class RoomChat {
218
276
  else { ids.push(id); shown++; }
219
277
  }
220
278
  this.statusTargets = statusTargets;
279
+ // Reserve restored Store intents before publishing a fresh idle presence can
280
+ // make the Bus drain a later queued envelope during asynchronous proof reads.
281
+ for (const [id, remote] of this.remotes) {
282
+ const target = this.targets.get(id);
283
+ if (!target) continue;
284
+ const queuedIds = new Set(this.options.bus.pendingEnvelopes(id).map(env => env.id));
285
+ for (const { binding, row } of this.outgoing.values()) {
286
+ if (binding === target.binding && row.remotePeer === id && row.recipient === target.member && row.peer === target.peer
287
+ && (row.state === "dispatching" || row.state === "accepted") && !row.disposition
288
+ && !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId) && !queuedIds.has(row.application)) {
289
+ remote.retainPending(row.deliveryId);
290
+ }
291
+ }
292
+ }
221
293
  for (const [id, remote] of this.remotes) {
222
294
  const target = this.targets.get(id);
223
295
  remote.presence(target?.peer === USER ? "idle" : target?.binding.states.get(id) ?? "offline", target?.binding.online === true);
@@ -240,14 +312,19 @@ export class RoomChat {
240
312
  this.refreshTargets();
241
313
  }
242
314
  linkState(store: RoomStore, name: string, link: RoomLink, state: RoomLinkState): void {
315
+ // stop() fences callbacks as part of ordinary shutdown; it is not a room
316
+ // trust revocation and must leave durable outbound intents resumable.
317
+ if (this.stopped) return;
243
318
  const binding = this.bindings.get(name);
244
319
  if (!binding || binding.store !== store || binding.link !== link) return;
245
- binding.online = state === "online";
320
+ // Application.online promotes readiness after membership and inbox recovery finish.
321
+ // A raw socket-online notification must not start the same recovery twice.
322
+ if (state !== "online") binding.online = false;
246
323
  // A transport blip does not revoke the last authenticated signed state.
247
324
  // A new roster digest still clears it in bind(), before target reconstruction.
248
325
  if (!this.valid(binding)) {
249
326
  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);
327
+ const unknown = binding.store.quarantineOutgoingPermanent(name, row.deliveryId, "boundary_refused"); if (unknown) this.outcome(binding, unknown);
251
328
  } } catch { for (const [key, row] of this.outgoing) if (row.binding === binding) this.outgoing.delete(key); }
252
329
  }
253
330
  this.refreshTargets();
@@ -263,10 +340,114 @@ export class RoomChat {
263
340
  }
264
341
  private outcome(binding: Binding, row: RoomOutgoing): void {
265
342
  const remote = this.remotes.get(row.remotePeer);
266
- if (row.lateReceipt) this.options.bus.noteLateRemoteReceipt(row.remotePeer, row.deliveryId, row.lateReceipt.outcome);
343
+ if (row.state === "needs_review" && !row.disposition) {
344
+ const journalRow = this.options.bus.queueShow(row.deliveryId);
345
+ if (journalRow?.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.deliveryId)) {
346
+ const held = this.personHeldInvocations.get(binding.name) ?? new Set<string>();
347
+ held.add(row.deliveryId); this.personHeldInvocations.set(binding.name, held);
348
+ }
349
+ }
350
+ if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) this.settleStoredOutcome(binding, remote, row);
351
+ else if (row.state === "needs_review" && !row.disposition && remote) {
352
+ if (!this.holdStoreNeedsReview(binding, row, remote)) remote.receipt({ id: row.deliveryId, state: "needs_review", reason: "room acknowledgement expired or outcome uncertain" });
353
+ }
267
354
  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
355
  this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
269
356
  }
357
+ private settleStoredOutcome(binding: Binding, remote: RemotePeer | undefined, row: RoomOutgoing): boolean {
358
+ if ((row.state !== "completed" && row.state !== "failed_safe") || row.disposition) return false;
359
+ const outcome = row.lateReceipt?.outcome ?? row.state;
360
+ const reason = row.lateReceipt?.reason ?? row.reason ?? (outcome === "completed" ? "admitted" : "boundary_refused");
361
+ const journalRow = this.options.bus.queueShow(row.deliveryId);
362
+ if (journalRow?.state === "needs_review" && journalRow.originals.some(isInvocationKind)) {
363
+ if (!this.options.bus.remoteDisposition(row.deliveryId)) this.options.bus.noteLateRemoteReceipt(row.remotePeer, row.deliveryId, outcome);
364
+ return false;
365
+ }
366
+ const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
367
+ const targetCurrent = target?.binding === binding && target.member === row.recipient && target.peer === row.peer;
368
+ const proofCurrent = () => this.valid(binding) && binding.store.get(binding.name)?.current.room === binding.room
369
+ && binding.store.get(binding.name)?.current.digest === state?.current.digest
370
+ && binding.store.applicationOutbox(binding.name).some(saved => saved.deliveryId === row.deliveryId
371
+ && saved.remotePeer === row.remotePeer && saved.recipient === row.recipient && saved.peer === row.peer
372
+ && saved.state === row.state && !saved.disposition);
373
+ if (!targetCurrent || !remote) {
374
+ const invalidated = () => proofCurrent() && (() => {
375
+ const next = this.targets.get(row.remotePeer);
376
+ return !next || next.binding !== binding || next.member !== row.recipient || next.peer !== row.peer;
377
+ })();
378
+ return this.options.bus.holdInvalidatedRemoteOutcome(row.remotePeer, row.deliveryId, binding.room, row.recipient, invalidated);
379
+ }
380
+ const current = () => this.valid(binding) && target?.binding === binding && target.member === row.recipient
381
+ && target.peer === row.peer && state?.current.room === binding.room
382
+ && binding.store.get(binding.name)?.current.digest === state?.current.digest;
383
+ const settled = this.options.bus.settleRemoteReceipt(remote, row.deliveryId, outcome,
384
+ outcome === "completed" ? `room ${completedText(reason)}` : `room ${roomReceiptReasonText(reason)}`, current);
385
+ if (settled) remote.settled(row.deliveryId);
386
+ return settled;
387
+ }
388
+ /** Reconcile a signed store commit that may have preceded a process crash or Bus CAS. */
389
+ private reconcileSignedOutcomes(binding: Binding): void {
390
+ if (!binding.online || !this.valid(binding)) return;
391
+ try {
392
+ for (const row of binding.store.applicationOutbox(binding.name)) {
393
+ if ((row.state === "completed" || row.state === "failed_safe") && !row.disposition) {
394
+ const remote = this.remotes.get(row.remotePeer);
395
+ this.settleStoredOutcome(binding, remote, row);
396
+ }
397
+ }
398
+ } catch { /* The next authenticated online edge retries the signed outcome reconciliation. */ }
399
+ }
400
+ /** Hold restart-era Bus rows only after signed Store outcomes have had their reconciliation chance. */
401
+ private reconcileMissingStoreIntents(binding: Binding): void {
402
+ if (!binding.online || !this.valid(binding)) return;
403
+ let saved: RoomOutgoing[];
404
+ try { saved = binding.store.applicationOutbox(binding.name); }
405
+ catch { return; }
406
+ const known = new Set(saved.map(row => row.deliveryId));
407
+ for (const [id, target] of this.targets) {
408
+ if (target.binding !== binding) continue;
409
+ const remote = this.remotes.get(id);
410
+ if (!remote) continue;
411
+ const digest = binding.digest;
412
+ for (const row of this.options.bus.queueList(id, true).slice(0, 200)) {
413
+ if ((row.state !== "dispatching" && row.state !== "accepted") || known.has(row.id)) continue;
414
+ const current = () => this.valid(binding) && binding.store.get(binding.name)?.current.digest === digest
415
+ && this.targets.get(id)?.binding === binding && this.targets.get(id)?.member === target.member
416
+ && this.targets.get(id)?.peer === target.peer && binding.store.get(binding.name)?.current.room === binding.room
417
+ && !binding.store.applicationOutbox(binding.name).some(savedRow => savedRow.deliveryId === row.id);
418
+ this.options.bus.holdRemoteMissingIntent(remote, row.id, current);
419
+ }
420
+ }
421
+ }
422
+ private holdStoreNeedsReview(binding: Binding, row: RoomOutgoing, remote: RemotePeer): boolean {
423
+ const journal = this.options.bus.queueShow(row.deliveryId);
424
+ if (!journal || row.disposition || (journal.state !== "dispatching" && journal.state !== "accepted" && journal.state !== "needs_review")) return false;
425
+ const current = this.currentOutgoingRow(binding, row);
426
+ if (journal.originals.some(isInvocationKind)) return false;
427
+ const held = this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable");
428
+ if (held) remote.settled(row.deliveryId);
429
+ return held;
430
+ }
431
+ /** Reconcile Store TTL/permanent holds and Bus-first proof holds before any resume starts. */
432
+ private reconcileProofUnavailable(binding: Binding): void {
433
+ if (!this.valid(binding)) return;
434
+ let rows: RoomOutgoing[];
435
+ try { rows = binding.store.applicationOutbox(binding.name); } catch { return; }
436
+ for (const row of rows) {
437
+ if (row.disposition) continue;
438
+ const remote = this.remotes.get(row.remotePeer);
439
+ const journal = this.options.bus.queueShow(row.deliveryId);
440
+ if (!remote || !journal) continue;
441
+ const heldProof = journal.state === "needs_review" && journal.reason === `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
442
+ if (heldProof && (row.state === "dispatching" || row.state === "accepted")) {
443
+ const current = this.currentOutgoingRow(binding, row);
444
+ if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) continue;
445
+ const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current);
446
+ if (quarantined) remote.settled(row.deliveryId);
447
+ } else if (row.state === "needs_review") this.holdStoreNeedsReview(binding, row, remote);
448
+ }
449
+ this.refreshOutgoing(binding);
450
+ }
270
451
  private rate(from: string, member: string, target: string, body: string): boolean {
271
452
  const config = this.options.limits?.() ?? PROJECT_LIMITS, key = JSON.stringify(config);
272
453
  if (key !== this.limitKey) { this.limitKey = key; this.peerLimiter = new Limiter(config, () => this.now()); }
@@ -284,6 +465,7 @@ export class RoomChat {
284
465
  if (!context.current() || this.stopped) throw new Error("room: state_changed");
285
466
  const binding = this.bindings.get(context.name);
286
467
  if (!binding || binding.store !== context.store || binding.link !== context.link) throw new Error("room: unavailable");
468
+ if (!this.reconcileDispositions(binding)) throw new RoomApplicationDeferredError("journal_unavailable");
287
469
  const saved = context.store.applicationReceipt(context.name, input.digest);
288
470
  if (saved) { await context.link.publishApplication(saved, input.sender, undefined, context.current); return; }
289
471
  if (input.message?.type === "receipt") {
@@ -363,16 +545,46 @@ export class RoomChat {
363
545
  if (receipt) await context.link.publishApplication(receipt, input.sender, undefined, context.current);
364
546
  this.options.changed?.();
365
547
  }
548
+ private refreshInvocationHolds(binding: Binding): void {
549
+ const room = binding.store.get(binding.name);
550
+ if (!room || !this.valid(binding)) return;
551
+ const held = new Set<string>();
552
+ try {
553
+ for (const member of roomMembers(room.current)) {
554
+ if (member.fingerprint === binding.store.identity.public.fingerprint) continue;
555
+ for (const peer of new Set([...member.exposedPeers, USER])) {
556
+ const id = `${peer}@${member.label}`;
557
+ for (const row of this.options.bus.queueList(id, true)) {
558
+ if (row.state === "needs_review" && row.originals.some(isInvocationKind) && !this.options.bus.remoteDisposition(row.id)) held.add(row.id);
559
+ }
560
+ }
561
+ }
562
+ } catch { /* Unknown Bus state never grants a proof resume by itself. */ }
563
+ this.personHeldInvocations.set(binding.name, held);
564
+ }
366
565
  queueChanged(): void {
367
566
  if (this.timer) clearTimeout(this.timer);
368
567
  this.timer = undefined; if (this.stopped || !this.bindings.size) return;
369
568
  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);
569
+ for (const { binding, row } of this.outgoing.values()) if ((row.state === "dispatching" || row.state === "accepted") && !row.disposition) {
570
+ const due = Math.min(row.expiresAt, binding.online ? row.ackDeadline ?? Infinity : Infinity); next = next === undefined ? due : Math.min(next, due);
372
571
  }
373
572
  if (next === undefined) return;
374
573
  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
574
  this.timer.unref?.();
575
+ this.resumeReadyIntents();
576
+ }
577
+ private resumeReadyIntents(): void {
578
+ if (this.stopped) return;
579
+ for (const binding of this.bindings.values()) {
580
+ if (!binding.online || !this.valid(binding)) continue;
581
+ try {
582
+ for (const row of binding.store.applicationOutbox(binding.name, true)) {
583
+ const remote = this.remotes.get(row.remotePeer);
584
+ if (remote?.readyToResume(row.deliveryId)) this.launchResume(binding, row);
585
+ }
586
+ } catch { /* An actual future queue, receipt or online event retries the scan. */ }
587
+ }
376
588
  }
377
589
  tick(): void {
378
590
  if (this.stopped) return;
@@ -380,12 +592,104 @@ export class RoomChat {
380
592
  for (const binding of this.bindings.values()) if (this.valid(binding)) {
381
593
  try {
382
594
  for (const row of binding.store.expireApplications(binding.name)) this.outcome(binding, row);
595
+ if (binding.online) {
596
+ const due = binding.store.dueApplicationRetries(binding.name);
597
+ this.refreshOutgoing(binding);
598
+ for (const row of due) this.launchResume(binding, row);
599
+ }
383
600
  this.reconcileDispositions(binding);
384
601
  }
385
602
  catch { binding.online = false; for (const [key, entry] of this.outgoing) if (entry.binding === binding) this.outgoing.delete(key); }
386
603
  }
387
604
  void this.retryPending(); this.queueChanged();
388
605
  }
606
+ private async resumeActive(binding: Binding): Promise<void> {
607
+ if (!binding.online || !this.valid(binding)) return;
608
+ try {
609
+ const active = binding.store.applicationOutbox(binding.name, true).filter(row => (row.state === "dispatching" || row.state === "accepted")
610
+ && !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId));
611
+ for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
612
+ for (const row of active) this.launchResume(binding, row);
613
+ } catch { /* The next authenticated online edge will retry durable rows. */ }
614
+ }
615
+ private launchResume(binding: Binding, row: RoomOutgoing): void {
616
+ const key = `${binding.name}:${row.deliveryId}`;
617
+ if (this.resuming.has(key)) return;
618
+ const remote = this.remotes.get(row.remotePeer);
619
+ if (!remote) return;
620
+ this.resuming.add(key);
621
+ void this.resumeApplication(binding, row, remote).catch(() => { remote.deferResume(row.deliveryId); })
622
+ .finally(() => { this.resuming.delete(key); try { this.queueChanged(); } catch { /* a later real event or ACK deadline retries */ } });
623
+ }
624
+ private storeProofCurrent(binding: Binding, row: RoomOutgoing, remote: RemotePeer, target: Target | undefined, digest: string | undefined): boolean {
625
+ try {
626
+ if (!target || !digest || !this.valid(binding) || binding.store.get(binding.name)?.current.room !== binding.room
627
+ || binding.store.get(binding.name)?.current.digest !== digest || this.targets.get(row.remotePeer)?.binding !== binding
628
+ || this.targets.get(row.remotePeer)?.member !== row.recipient || this.targets.get(row.remotePeer)?.peer !== row.peer
629
+ || !remote.isPendingHead(row.deliveryId) || row.disposition || row.expiresAt <= this.now()) return false;
630
+ return binding.store.applicationOutbox(binding.name, true).some(saved => saved.deliveryId === row.deliveryId
631
+ && saved.application === row.application && saved.remotePeer === row.remotePeer && saved.recipient === row.recipient
632
+ && saved.peer === row.peer && (saved.state === "dispatching" || saved.state === "accepted") && !saved.disposition);
633
+ } catch { return false; }
634
+ }
635
+ private holdUnavailableProof(binding: Binding, row: RoomOutgoing, remote: RemotePeer, current: () => boolean): boolean {
636
+ const busRow = this.options.bus.queueShow(row.deliveryId);
637
+ if (busRow?.originals.some(isInvocationKind)) { remote.deferResume(row.deliveryId); return false; }
638
+ if (!this.options.bus.holdRemoteMissingIntent(remote, row.deliveryId, current, "proof-unavailable")) { remote.deferResume(row.deliveryId); return false; }
639
+ try { binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", current); }
640
+ catch { /* Bus hold is durable; startup reconciliation can finish the Store side. */ }
641
+ remote.settled(row.deliveryId);
642
+ this.refreshOutgoing(binding); this.queueChanged(); this.options.changed?.();
643
+ return true;
644
+ }
645
+ private async resumeApplication(binding: Binding, row: RoomOutgoing, remote: RemotePeer): Promise<void> {
646
+ if (this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return;
647
+ if (!remote || !remote.isPendingHead(row.deliveryId)) return;
648
+ const heldRow = this.options.bus.queueShow(row.deliveryId);
649
+ const original = this.options.bus.remoteDeliveryEnvelope(remote, row.deliveryId);
650
+ const proofHoldReason = `${ROOM_PROOF_UNAVAILABLE_REASON} ${row.deliveryId}`;
651
+ if (heldRow?.state === "needs_review" && heldRow.reason === proofHoldReason && original) {
652
+ if (!row.disposition && (row.state === "dispatching" || row.state === "accepted")) {
653
+ const digest = binding.store.get(binding.name)?.current.digest;
654
+ const target = this.targets.get(row.remotePeer);
655
+ const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
656
+ const quarantined = binding.store.quarantineOutgoingPermanent(binding.name, row.deliveryId, "boundary_refused", proofCurrent);
657
+ if (quarantined) { remote.settled(row.deliveryId); this.refreshOutgoing(binding); this.queueChanged(); }
658
+ else remote.deferResume(row.deliveryId);
659
+ }
660
+ return;
661
+ }
662
+ if (!original || !binding.online || !this.valid(binding) || row.disposition || row.expiresAt <= this.now()) { remote.deferResume(row.deliveryId); return; }
663
+ const target = this.targets.get(row.remotePeer), state = binding.store.get(binding.name);
664
+ const digest = state?.current.digest;
665
+ const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
666
+ if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
667
+ const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
668
+ if (!ready()) { remote.waitForReady(row.deliveryId); return; }
669
+ let readinessLost = false;
670
+ const current = () => {
671
+ const allowed = ready();
672
+ if (!allowed) readinessLost = true;
673
+ return allowed && proofCurrent();
674
+ };
675
+ let saved: RoomMessage | undefined;
676
+ try { saved = await binding.store.outgoingMessage(binding.name, row.application, current); }
677
+ catch (error) {
678
+ if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
679
+ if (readinessLost || !ready()) { remote.waitForReady(row.deliveryId); return; }
680
+ if (permanentOutboundProofFailure(error)) { this.holdUnavailableProof(binding, row, remote, proofCurrent); return; }
681
+ remote.deferResume(row.deliveryId); return;
682
+ }
683
+ if (!proofCurrent()) { remote.deferResume(row.deliveryId); return; }
684
+ if (!ready()) { remote.waitForReady(row.deliveryId); return; }
685
+ if (!saved || saved.type !== "chat" || saved.envelope.id !== row.application || saved.envelope.to !== row.peer) {
686
+ this.holdUnavailableProof(binding, row, remote, proofCurrent); return;
687
+ }
688
+ const resumeState = row.state === "accepted" ? "accepted" : "dispatching";
689
+ if (!this.options.bus.resumeRemotePending(remote, row.deliveryId, resumeState, current)) return;
690
+ await remote.resume(original, row.deliveryId, row.expiresAt, saved.envelope);
691
+ this.refreshOutgoing(binding); this.queueChanged();
692
+ }
389
693
  /** Global byte capacity can be freed by another room. This is event-coalesced, never polled. */
390
694
  private capacityChanged(): void {
391
695
  if (this.stopped || this.capacityRefreshPending) return;
@@ -407,19 +711,31 @@ export class RoomChat {
407
711
  } catch { /* A completed person disposition must not fail because another room is unavailable. */ }
408
712
  await Promise.allSettled(retries); this.capacityChanged();
409
713
  }
410
- private reconcileDispositions(binding: Binding): void {
411
- for (const row of binding.store.applicationOutbox(binding.name, true)) if (row.state === "needs_review" && !row.disposition) {
714
+ private reconcileDispositions(binding: Binding): boolean {
715
+ let complete = true;
716
+ for (const row of binding.store.applicationOutbox(binding.name)) if (!row.disposition) {
412
717
  const action = this.options.bus.remoteDisposition(row.deliveryId);
413
- if (action) binding.store.disposeApplication(binding.name, row.deliveryId, action, () => !this.stopped);
718
+ if (action) try {
719
+ const disposed = binding.store.disposeApplication(binding.name, row.deliveryId, action, true, () => this.valid(binding)
720
+ && binding.store.get(binding.name)?.current.digest === binding.digest && this.options.bus.remoteDisposition(row.deliveryId) === action);
721
+ if (disposed?.disposition === action) this.remotes.get(row.remotePeer)?.settled(row.deliveryId);
722
+ }
723
+ catch { complete = false; }
414
724
  }
415
725
  this.refreshOutgoing(binding);
726
+ return complete;
416
727
  }
417
728
  disposition(peer: string, deliveryId: string, action: "completed" | "retry" | "discard"): void {
418
729
  if (this.options.bus.remoteDisposition(deliveryId) !== action) return;
419
730
  for (const binding of this.bindings.values()) {
420
- const row = binding.store.applicationOutbox(binding.name, true).find(row => row.deliveryId === deliveryId && row.remotePeer === peer);
731
+ const row = binding.store.applicationOutbox(binding.name).find(row => row.deliveryId === deliveryId && row.remotePeer === peer);
421
732
  if (!row) continue;
422
- binding.store.disposeApplication(binding.name, deliveryId, action, () => !this.stopped);
733
+ const room = binding.room, digest = binding.digest;
734
+ const disposed = binding.store.disposeApplication(binding.name, deliveryId, action, true, () => this.valid(binding)
735
+ && binding.store.get(binding.name)?.current.room === room && binding.store.get(binding.name)?.current.digest === digest
736
+ && this.options.bus.remoteDisposition(deliveryId) === action);
737
+ if (disposed?.disposition === action) this.remotes.get(peer)?.settled(deliveryId);
738
+ this.personHeldInvocations.get(binding.name)?.delete(deliveryId);
423
739
  this.refreshOutgoing(binding); break;
424
740
  }
425
741
  this.refreshTargets(); this.queueChanged(); void this.retryPending();