@staix/agent-hub 0.29.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -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 +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. package/src/pet/window.ts +0 -154
package/src/rooms/link.ts CHANGED
@@ -3,7 +3,7 @@ import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "..
3
3
  import { assertRoomCryptoReady, encodeBase64url, sha256, RoomCryptoError } from "./crypto.ts";
4
4
  import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, openFrame, roomMembers, sealFrame, verifyPublicRoster, RoomWireError, type FrameWire, type FrameOpenResult, type VerifiedPublicRoster } from "./wire.ts";
5
5
  import type { RoomRefusalReason, RoomState, RoomStore } from "./store.ts";
6
- import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, type RoomOutgoingInput } from "./store.ts";
6
+ import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, RoomStoreError, type RoomOutgoingInput } from "./store.ts";
7
7
  import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.ts";
8
8
 
9
9
  export type RoomLinkState = "offline" | "connecting" | "online";
@@ -28,6 +28,7 @@ export interface RoomLinkApplication {
28
28
  stored(context: RoomLinkContext, input: { digest: string; seq: string }): void;
29
29
  /** Fixed transport metadata only; never application completion or authority. */
30
30
  transportAck?(context: RoomLinkContext, input: { digest: string }): void;
31
+ receiptTtlMs?(context: RoomLinkContext): number;
31
32
  capacityChanged?(context: RoomLinkContext): void;
32
33
  gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
33
34
  }
@@ -40,12 +41,31 @@ export class RoomLinkError extends Error {
40
41
  super(`Room link: ${code}`); this.name = "RoomLinkError";
41
42
  }
42
43
  }
44
+ /** Only before durable application enqueue: the Bus may retain its original queue item. */
45
+ export class RoomNotSentError extends Error {
46
+ readonly code = "not_sent";
47
+ constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
48
+ }
49
+ const RECEIPT_TTL_MS = 24 * 3_600_000;
43
50
  const LIMITS = Object.freeze({ packets: 8, queuedBytes: 2_097_152, packetBytes: 1_048_576, backpressure: 1_048_576, handshakeMs: 30_000, reconnectMaxMs: 300_000 });
44
51
  const decoder = new TextDecoder("utf-8", { fatal: true });
45
52
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
46
53
  const DIGEST = /^[A-Za-z0-9_-]{43}$/;
47
54
  const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
55
+ interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
48
56
  interface Session {
57
+ processingFrame?: string;
58
+ acknowledgedFrame?: string;
59
+ pendingPresenceReplies: Set<string>;
60
+ presenceAsked: boolean;
61
+ presenceRoster?: string;
62
+ outbound: PendingFrame[];
63
+ outboundBytes: number;
64
+ frameSent: boolean;
65
+ retryAttempt: number;
66
+ frameRetry?: ReturnType<typeof setTimeout>;
67
+ lastPresence?: string;
68
+ ephemeral?: boolean;
49
69
  ws: WebSocket;
50
70
  generation: number;
51
71
  room: string;
@@ -165,6 +185,7 @@ export class RoomLink {
165
185
  if (!this.publicationCurrent(session, row.current.digest) || !this.options.store.ordinaryInboxAvailable(this.options.name, bytes)) return;
166
186
  // Intent survives coalescing, ordinary responses and publication failure.
167
187
  // Only an actually sent request with current capacity may clear the numeric flag.
188
+ if (!this.forcePresenceRequest) { session.pendingPresenceReplies.clear(); session.presenceAsked = false; }
168
189
  session.requestPresenceReply = true; this.forcePresenceRequest = true;
169
190
  try { await this.publishPresence(); }
170
191
  catch (error) { this.retry(session); throw error; }
@@ -233,6 +254,8 @@ export class RoomLink {
233
254
  this.republish = undefined;
234
255
  if (session) {
235
256
  clearTimeout(session.handshake);
257
+ if (session.frameRetry) clearTimeout(session.frameRetry);
258
+ session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
236
259
  session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
237
260
  try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
238
261
  }
@@ -259,10 +282,11 @@ export class RoomLink {
259
282
  } catch { this.setState("offline"); return; }
260
283
  url.pathname = `/v1/rooms/${row.current.room}`;
261
284
  let ws: WebSocket;
262
- try { ws = new WebSocket(url.href); } catch { this.retry(); return; }
285
+ // ponytail: negotiate only ephemeral here; replace this opt-in with #1204 T4 codec capabilities.
286
+ try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
263
287
  ws.binaryType = "arraybuffer";
264
288
  const generation = ++this.generation;
265
- const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
289
+ const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", pendingPresenceReplies: new Set(), presenceAsked: false, outbound: [], outboundBytes: 0, frameSent: false, retryAttempt: 0, queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
266
290
  handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
267
291
  session.handshake.unref?.();
268
292
  this.session = session;
@@ -276,6 +300,59 @@ export class RoomLink {
276
300
  if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
277
301
  session.ws.send(bytes);
278
302
  }
303
+ private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
304
+ this.current(session);
305
+ // Only an unsent observation may be replaced. Never reorder an attempted frame.
306
+ if (presence) {
307
+ const at = session.outbound.findIndex((frame, index) => frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
308
+ if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
309
+ }
310
+ if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
311
+ session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
312
+ this.pumpFrames(session);
313
+ }
314
+ private pumpFrames(session: Session): void {
315
+ if (session.frameSent || session.frameRetry || !session.outbound.length) return;
316
+ this.current(session);
317
+ const frame = session.outbound[0]!;
318
+ if (frame.retained && !frame.retained()) {
319
+ // Disposed/expired intent cannot survive in the transport's RAM queue.
320
+ session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
321
+ this.pumpFrames(session); return;
322
+ }
323
+ if (!frame.current()) {
324
+ if (!frame.presence) fail("state_changed");
325
+ session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.lastPresence = undefined;
326
+ this.pumpFrames(session);
327
+ void this.publishPresence().catch(() => this.retry(session));
328
+ return;
329
+ }
330
+ // A foreground receipt follows the chat ACK on the socket. Otherwise broker
331
+ // verification/storage of the receipt can pin the chat's one-packet slot.
332
+ if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
333
+ this.send(session, frame.wire); session.frameSent = true;
334
+ const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
335
+ }
336
+ private storedFrame(session: Session, digest: string): void {
337
+ if (session.outbound[0]?.digest !== digest) return;
338
+ const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
339
+ session.frameSent = false; session.retryAttempt = 0;
340
+ if (session.frameRetry) clearTimeout(session.frameRetry);
341
+ session.frameRetry = undefined;
342
+ this.pumpFrames(session);
343
+ }
344
+ private backoffFrame(session: Session, digest: string): void {
345
+ if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
346
+ session.frameSent = false;
347
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
348
+ session.frameRetry = setTimeout(() => {
349
+ session.frameRetry = undefined;
350
+ if (this.session !== session || !this.running || session.phase !== "ready") return;
351
+ try { this.pumpFrames(session); } catch { this.retry(session); }
352
+ }, delay);
353
+ session.frameRetry.unref?.();
354
+ this.pumpFrames(session);
355
+ }
279
356
  private receive(session: Session, data: unknown): void {
280
357
  if (this.session !== session || !this.running) return;
281
358
  if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > LIMITS.packetBytes || session.queue.length + (session.deferredFrame ? 1 : 0) >= LIMITS.packets || session.queuedBytes + (session.deferredFrame?.byteLength ?? 0) + data.byteLength > LIMITS.queuedBytes) { this.retry(session); return; }
@@ -327,13 +404,19 @@ export class RoomLink {
327
404
  return;
328
405
  }
329
406
  if (message.type === "ready") {
330
- const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest"]), roster = session.roster;
407
+ const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
408
+ if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
331
409
  if (!roster || ready.room !== local.current.room || ready.member !== this.options.store.identity.public.fingerprint || ready.epoch !== roster.epoch || ready.digest !== roster.digest) fail("untrusted");
332
410
  if (roster.digest !== local.current.digest) {
333
411
  if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
334
412
  session.ownerUpgrading = true;
335
413
  this.send(session, local.wire); return;
336
414
  }
415
+ if (session.presenceRoster !== roster.digest) {
416
+ session.presenceRoster = roster.digest; session.pendingPresenceReplies.clear(); session.presenceAsked = false;
417
+ session.requestPresenceReply = true; session.lastPresence = undefined;
418
+ }
419
+ session.ephemeral = ready.ephemeral === true;
337
420
  session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
338
421
  if (session.deferredFrame) {
339
422
  session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
@@ -341,6 +424,7 @@ export class RoomLink {
341
424
  this.setState("online");
342
425
  const context = this.context(session, local.current.digest);
343
426
  this.options.application?.members(context);
427
+ this.restoreReceipts(session);
344
428
  await this.retryPendingApplications();
345
429
  this.current(session, local.current.digest);
346
430
  await this.options.application?.online(context);
@@ -365,7 +449,14 @@ export class RoomLink {
365
449
  if (message.type === "stored") {
366
450
  const row = exact(message, ["v", "type", "digest", "seq", "duplicate"]);
367
451
  if (typeof row.digest !== "string" || !DIGEST.test(row.digest) || typeof row.seq !== "string" || !/^[1-9][0-9]{0,19}$/.test(row.seq) || typeof row.duplicate !== "boolean") fail();
452
+ const receipt = this.options.store.receiptSendable(this.options.name, row.digest);
453
+ if (receipt) {
454
+ this.options.store.markStored(this.options.name, row.digest, row.seq);
455
+ this.options.store.retireStoredReceipt(this.options.name, row.digest);
456
+ }
457
+ this.storedFrame(session, row.digest);
368
458
  this.options.application?.stored(this.context(session, local.current.digest), { digest: row.digest, seq: row.seq });
459
+ this.restoreReceipts(session);
369
460
  return; // Broker storage is not a signed recipient application-completion receipt.
370
461
  }
371
462
  if (message.type === "queue_ack") {
@@ -381,10 +472,17 @@ export class RoomLink {
381
472
  return;
382
473
  }
383
474
  if (message.type === "error") {
384
- const error = exact(message, ["v", "type", "code"]);
475
+ const error = exact(message, Object.hasOwn(message, "digest") ? ["v", "type", "code", "digest"] : ["v", "type", "code"]);
476
+ if (error.digest !== undefined && (error.code !== "queue_full" || typeof error.digest !== "string" || !DIGEST.test(error.digest))) fail();
477
+ if (error.code === "queue_full") {
478
+ // Older brokers omit correlation; one frame in flight makes its head unambiguous.
479
+ const digest = typeof error.digest === "string" ? error.digest : session.outbound[0]?.digest;
480
+ if (digest) this.backoffFrame(session, digest);
481
+ return;
482
+ }
385
483
  // X2 declares these refusal controls nonterminal. Their unsigned control
386
484
  // cannot settle any application; its local ACK deadline records uncertainty.
387
- if (error.code === "queue_full" || error.code === "replay_refused" || error.code === "unexpected_ack") return;
485
+ if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
388
486
  fail("unavailable");
389
487
  }
390
488
  fail();
@@ -449,6 +547,7 @@ export class RoomLink {
449
547
  else throw error;
450
548
  }
451
549
  const acceptedDigest = await this.frameDigest(bytes);
550
+ session.processingFrame = acceptedDigest;
452
551
  const latest = this.frameCurrent(session, frame);
453
552
  if (latest.current.digest !== digest) refused = "trust_changed";
454
553
  if (result?.status === "stale") {
@@ -471,6 +570,8 @@ export class RoomLink {
471
570
  } else {
472
571
  const peers = presence(result.plaintext, sender.exposedPeers);
473
572
  this.current(session, digest);
573
+ session.pendingPresenceReplies.delete(frame.sender);
574
+ if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
474
575
  try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
475
576
  this.current(session, digest);
476
577
  this.options.store.settleInbox(this.options.name, acceptedDigest);
@@ -486,9 +587,32 @@ export class RoomLink {
486
587
  this.frameCurrent(session, frame);
487
588
  // This ACK only retires ciphertext from the broker queue. A durable refusal
488
589
  // or stale drop is never a signed recipient application-completion receipt.
489
- this.send(session, encodeBrokerControl({ v: 1, type: "received", digest: acceptedDigest }));
590
+ this.retireFrame(session, frame, acceptedDigest);
591
+ session.processingFrame = undefined; session.acknowledgedFrame = undefined;
490
592
  await this.refreshPresenceIfCapacityFree().catch(() => undefined);
491
593
  }
594
+ private retireFrame(session: Session, frame: FrameWire, digest: string): void {
595
+ this.frameCurrent(session, frame);
596
+ // Final admission commits its receipt; deferred admission retains the durable inbox.
597
+ // Waiting for broker storage or deferred admission would pin capacity one indefinitely.
598
+ if (this.options.store.applicationReceipt(this.options.name, digest)
599
+ && !this.options.store.receiptStored(this.options.name, digest)) fail("unavailable");
600
+ this.send(session, encodeBrokerControl({ v: 1, type: "received", digest }));
601
+ session.acknowledgedFrame = digest;
602
+ this.pumpFrames(session);
603
+ }
604
+ private restoreReceipts(session: Session): void {
605
+ this.current(session);
606
+ this.options.store.expireReceipts(this.options.name);
607
+ for (const entry of this.options.store.outbox(this.options.name)) {
608
+ if (entry.kind !== "receipt" || session.outbound.some(frame => frame.digest === entry.digest)) continue;
609
+ if (session.outbound.length >= LIMITS.packets || session.outboundBytes + entry.wire.byteLength > LIMITS.queuedBytes) break;
610
+ const roster = this.current(session).current.digest;
611
+ this.queueFrame(session, entry.wire, entry.digest, false, () => this.publicationCurrent(session, roster), false,
612
+ () => this.options.store.receiptSendable(this.options.name, entry.digest), undefined,
613
+ entry.receiptFor === session.processingFrame ? entry.receiptFor : undefined);
614
+ }
615
+ }
492
616
  private async frameDigest(bytes: Uint8Array): Promise<string> {
493
617
  return encodeBase64url(await sha256(bytes));
494
618
  }
@@ -511,29 +635,86 @@ export class RoomLink {
511
635
  async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
512
636
  const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
513
637
  const session = this.session;
514
- if (!session || session.phase !== "ready") fail("unavailable");
638
+ if (!session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
515
639
  const digest = this.current(session).current.digest;
516
640
  const allowed = () => this.publicationCurrent(session, digest) && current();
517
- return this.serialSend(async () => {
641
+ let queueStarted = false;
642
+ try {
643
+ let presenceWork: Promise<void> | undefined;
644
+ // A chat is the transport barrier for earlier observations. Receipts keep
645
+ // their durable-commit/ACK path and do not wait behind a new observation.
646
+ if (message.type === "chat" && this.republish) {
647
+ clearTimeout(this.republish); this.republish = undefined;
648
+ presenceWork = this.publishPresence();
649
+ // It may fail before reaching the serial lane. Observe rejection now;
650
+ // the reserved chat slot still awaits the original error below.
651
+ void presenceWork.catch(() => undefined);
652
+ }
653
+ // Reserve this chat's FIFO slot before yielding to the earlier observation.
654
+ return await this.serialSend(async () => {
655
+ if (presenceWork) await presenceWork;
518
656
  if (!allowed()) fail("state_changed");
657
+ if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
658
+ || session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
659
+ if (message.type === "receipt") {
660
+ const pending = this.options.store.receiptStored(this.options.name, message.frame);
661
+ if (pending?.state === "dispatching") {
662
+ if (!decodeFrame(pending.wire).recipients.includes(recipient)) fail("untrusted");
663
+ this.restoreReceipts(session);
664
+ return pending.digest;
665
+ }
666
+ }
519
667
  const row = this.current(session, digest);
520
668
  if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
521
- const wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
669
+ let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
522
670
  if (!allowed()) fail("state_changed");
523
- const record = metadata ? await this.options.store.queueApplication(this.options.name, wire, metadata, allowed) : undefined;
524
- const frameDigest = record?.digest ?? await this.frameDigest(wire);
671
+ let frameDigest: string;
672
+ if (metadata) {
673
+ queueStarted = true;
674
+ let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
675
+ try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, allowed); }
676
+ catch (error) {
677
+ // An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
678
+ if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
679
+ && !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
680
+ throw error;
681
+ }
682
+ frameDigest = record.frameDigest ?? record.digest;
683
+ } else if (message.type === "receipt") {
684
+ const ttlMs = this.options.application?.receiptTtlMs?.(this.context(session, digest)) ?? RECEIPT_TTL_MS;
685
+ const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, allowed);
686
+ if (!allowed()) fail("state_changed");
687
+ if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
688
+ frameDigest = receipt.digest; wire = receipt.wire;
689
+ } else frameDigest = await this.frameDigest(wire);
525
690
  if (!allowed()) fail("state_changed");
526
- this.send(session, wire);
691
+ try {
692
+ this.queueFrame(session, wire, frameDigest, false, allowed, false,
693
+ metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
694
+ : message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
695
+ undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
696
+ } catch (error) {
697
+ // The durable outbox owns retry after local backpressure; this is no uncertainty.
698
+ if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
699
+ if (session.outbound[0]?.digest === frameDigest) this.backoffFrame(session, frameDigest);
700
+ }
527
701
  return frameDigest;
528
- });
702
+ }); } catch (error) {
703
+ if (metadata && !queueStarted) throw new RoomNotSentError();
704
+ throw error;
705
+ }
529
706
  }
530
707
  async publishPresence(): Promise<void> {
531
708
  const session = this.session;
532
709
  if (!session || session.phase !== "ready") return;
533
710
  if (this.publishing) { this.publishDirty = true; return; }
711
+ // Keep the already scheduled coalesced snapshot. Cancelling it on every
712
+ // state flip turns an awaited burst back into one durable seal per call.
713
+ if (this.republish && !this.replyPresence && !this.forcePresenceRequest) { this.publishDirty = true; return; }
534
714
  if (this.republish) clearTimeout(this.republish);
535
715
  this.republish = undefined; this.publishDirty = false;
536
- const response = this.replyPresence && !this.forcePresenceRequest;
716
+ const forceRequest = this.forcePresenceRequest;
717
+ const response = this.replyPresence && !forceRequest;
537
718
  if (response) this.replyPresence = false;
538
719
  this.forcePresenceRequest = false;
539
720
  let sent = false;
@@ -549,17 +730,24 @@ export class RoomLink {
549
730
  await this.serialSend(async () => {
550
731
  if (!this.publicationCurrent(session, row.current.digest)) return;
551
732
  const requestReply = !response && session.requestPresenceReply;
552
- const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers, ...(requestReply ? { requestReply: true } : {}) }), sequence: this.options.store.sequence });
733
+ const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
734
+ if (!response && !forceRequest && session.lastPresence === presenceKey) return;
735
+ const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers, ...(requestReply ? { requestReply: true } : {}) }), ...(session.ephemeral ? { ephemeral: true as const } : {}), sequence: this.options.store.sequence });
553
736
  if (!this.publicationCurrent(session, row.current.digest)) return;
554
737
  // Machine-local exposure can change during reserve/encryption/signing even
555
738
  // when the signed roster is unchanged. Never emit an id it now withholds.
739
+ const frameDigest = await this.frameDigest(wire);
740
+ if (!this.publicationCurrent(session, row.current.digest)) return;
556
741
  const allowed = new Set(this.currentPresence(own.exposedPeers).map(peer => peer.id));
557
742
  if (peers.some(peer => !allowed.has(peer.id))) return;
558
- this.send(session, wire); sent = true;
559
- if (requestReply) {
560
- session.requestPresenceReply = false;
561
- if (this.retiredPresenceBytes && this.options.store.ordinaryInboxAvailable(this.options.name, this.retiredPresenceBytes)) this.retiredPresenceBytes = 0;
562
- }
743
+ this.queueFrame(session, wire, frameDigest, requestReply,
744
+ () => this.publicationCurrent(session, row.current.digest) && peers.every(peer => this.currentPresence(own.exposedPeers).some(value => value.id === peer.id)), true, undefined,
745
+ requestReply ? () => {
746
+ if (!session.presenceAsked) { session.pendingPresenceReplies = new Set(recipients); session.presenceAsked = true; }
747
+ session.requestPresenceReply = session.pendingPresenceReplies.size > 0;
748
+ if (this.retiredPresenceBytes && this.options.store.ordinaryInboxAvailable(this.options.name, this.retiredPresenceBytes)) this.retiredPresenceBytes = 0;
749
+ } : undefined);
750
+ session.lastPresence = presenceKey; sent = true;
563
751
  });
564
752
  } catch (error) {
565
753
  this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
@@ -1,16 +1,27 @@
1
1
  /** A host-owned room transport, never a task owner, reviewer, tool caller or native runtime. */
2
2
  import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
3
3
  import type { Envelope, PeerState } from "../hub/envelope.ts";
4
- import { parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
4
+ import { encodeRoomMessage, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
5
5
  import { readRoomOutbound, roomBoundary, type RoomBoundaryContext } from "./boundary.ts";
6
+ import { RoomNotSentError } from "./link.ts";
6
7
 
7
8
  export interface RemotePeerPorts {
8
9
  member: string;
9
10
  context: (env: Envelope) => RoomBoundaryContext;
10
11
  send: (env: RoomChatEnvelope, deliveryId: string, expiresAt: number, current: () => boolean, withheld: boolean) => Promise<void>;
11
12
  now?: () => number;
13
+ /** Current Bus admission, separate from immutable room trust/exposure proof. */
14
+ ready?: () => boolean;
12
15
  /** Record post-publication trust loss locally before any late signed outcome can settle it. */
13
- quarantine?: (deliveryId: string) => void;
16
+ quarantine?: (deliveryId: string, reason?: "not_exposed" | "boundary_refused" | "invalid_message") => void;
17
+ /** Requeue a send that the room link proves never reached its durable outbox. */
18
+ notSent?: (deliveryId: string) => boolean;
19
+ /** True for any durable RoomStore row with this Bus delivery ID; false only proves absence. */
20
+ queued?: (deliveryId: string) => boolean | undefined;
21
+ /** Persist a conservative Bus hold when neither requeue nor durable Store retry is available. */
22
+ holdMissing?: (deliveryId: string) => boolean;
23
+ /** Wake host retry scheduling after an active intent meets an external Bus fence. */
24
+ waiting?: () => void;
14
25
  }
15
26
 
16
27
  export class RemotePeer extends BasePeer {
@@ -20,8 +31,11 @@ export class RemotePeer extends BasePeer {
20
31
  private generation = 0;
21
32
  private running = false;
22
33
  private online = false;
34
+ private blockedNotSent = false;
23
35
  private observed: PeerState = "offline";
24
36
  private readonly pending = new Set<string>();
37
+ private readonly waitingForReady = new Set<string>();
38
+ private readonly sending = new Set<string>();
25
39
  private readonly deadlines = new Map<string, number>();
26
40
  constructor(id: string, readonly remoteRoom: string, readonly remoteQueueTtlMs: number, private readonly ports: RemotePeerPorts) {
27
41
  super(id);
@@ -31,50 +45,203 @@ export class RemotePeer extends BasePeer {
31
45
  }
32
46
  // Room ack/TTL deadlines, not the native inactivity watchdog, own transport settlement.
33
47
  protected override touch(): void { this.clearWatchdog(); }
34
- private update(): void { this.setState(this.pending.size ? "busy" : this.running && this.online ? this.observed : "offline"); }
48
+ private update(): void { this.setState(this.pending.size ? "busy" : this.running && this.online && !this.blockedNotSent ? this.observed : "offline"); }
49
+ private head(): string | undefined { return this.pending.values().next().value as string | undefined; }
50
+ /** Reserve recovered Store intents synchronously before any proof decrypt/screen await. */
51
+ reservePending(deliveryId: string): void {
52
+ this.pending.add(deliveryId);
53
+ this.waitingForReady.add(deliveryId);
54
+ this.blockedNotSent = false;
55
+ this.update();
56
+ }
35
57
  async start(): Promise<void> { this.running = true; this.generation++; this.update(); }
36
58
  async stop(): Promise<void> {
37
59
  this.running = false; this.generation++;
38
- for (const id of [...this.pending]) { this.quarantine(id); this.receipt({ id, state: "needs_review", reason: "room transport stopped" }); }
60
+ // Active journal rows and their sealed room frames survive a sender restart.
61
+ // Stop only fences in-flight callbacks; it is not evidence of an uncertain send.
62
+ this.pending.clear();
63
+ this.waitingForReady.clear();
39
64
  this.deadlines.clear(); this.update();
40
65
  }
41
- private quarantine(id: string): void { try { this.ports.quarantine?.(id); } catch { /* the Bus still retains uncertainty if room storage is unavailable */ } }
42
- presence(state: PeerState, online: boolean): void { this.observed = state; this.online = online; this.update(); }
66
+ private quarantine(id: string, reason?: "not_exposed" | "boundary_refused" | "invalid_message"): void { try { this.ports.quarantine?.(id, reason); } catch { /* The durable application remains for TTL review if local storage is unavailable. */ } }
67
+ private ready(): boolean { try { return this.ports.ready?.() !== false; } catch { return false; } }
68
+ waitForReady(deliveryId: string): void {
69
+ this.waitingForReady.add(deliveryId);
70
+ this.pending.add(deliveryId);
71
+ this.blockedNotSent = false;
72
+ this.update();
73
+ try { this.ports.waiting?.(); } catch { /* A future real Bus callback will retry. */ }
74
+ }
75
+ /** Consume an immediate resume token while retaining the active FIFO head. */
76
+ deferResume(deliveryId: string): void { this.waitingForReady.delete(deliveryId); }
77
+ readyToResume(deliveryId: string): boolean {
78
+ return this.waitingForReady.has(deliveryId) && this.head() === deliveryId && this.running && this.online && !this.blockedNotSent && this.ready();
79
+ }
80
+ isPendingHead(deliveryId: string): boolean { return this.head() === deliveryId; }
81
+ private holdMissing(id: string): boolean { try { return this.ports.holdMissing?.(id) === true; } catch { return false; } }
82
+ private notSent(id: string, blockUntilWake = true): void {
83
+ let retained: boolean | undefined;
84
+ try { retained = this.ports.queued?.(id); } catch { retained = undefined; }
85
+ if (retained === true) {
86
+ // A prior attempt is already durable. Never create a second Bus delivery ID.
87
+ if (!blockUntilWake || !this.ready()) this.waitForReady(id);
88
+ else { this.blockedNotSent = true; this.update(); }
89
+ return;
90
+ }
91
+ if (retained === undefined) {
92
+ // Store read failure cannot prove not-sent, so preserve the active RAM fence.
93
+ this.blockedNotSent = true; this.update(); return;
94
+ }
95
+ let requeued = false;
96
+ try { requeued = this.ports.notSent?.(id) === true; } catch { /* Keep uncertainty handling on the state transition below. */ }
97
+ // An expired requeue may have completed the Bus row synchronously. In that
98
+ // case its receipt already removed this ID and drained the following FIFO item.
99
+ if (!this.pending.has(id)) { this.update(); return; }
100
+ if (requeued) {
101
+ this.waitingForReady.delete(id);
102
+ this.blockedNotSent = blockUntilWake;
103
+ this.pending.delete(id);
104
+ this.update();
105
+ return;
106
+ }
107
+ const held = retained === false && this.holdMissing(id);
108
+ if (held) {
109
+ this.waitingForReady.delete(id);
110
+ this.blockedNotSent = false;
111
+ this.pending.delete(id);
112
+ } else this.blockedNotSent = true; // Keep the ID busy if the journal write failed or the actor went stale.
113
+ this.update();
114
+ }
115
+ presence(state: PeerState, online: boolean): void {
116
+ if (online && (!this.online || state !== this.observed)) this.blockedNotSent = false;
117
+ this.observed = state; this.online = online; this.update();
118
+ }
119
+ /** Rehydrate a journal-owned delivery without creating a new bus delivery or application id. */
120
+ async resume(env: Envelope, deliveryId: string, expiresAt: number, immutable: RoomChatEnvelope): Promise<void> {
121
+ if (!this.running || !this.online || !this.ready() || this.blockedNotSent || !deliveryId || this.head() !== deliveryId || !Number.isSafeInteger(expiresAt) || expiresAt <= (this.ports.now?.() ?? Date.now())) return;
122
+ this.waitingForReady.delete(deliveryId);
123
+ this.pending.add(deliveryId); this.update();
124
+ await this.publish(env, deliveryId, expiresAt, this.generation, immutable);
125
+ }
126
+ /** A link becoming ready is an actual wake-up after a typed not-sent refusal. */
127
+ wake(): void { this.blockedNotSent = false; this.update(); }
43
128
  setRemoteDeadline(envelopeId: string, expiresAt: number): void {
44
129
  if (Number.isSafeInteger(expiresAt) && expiresAt > 0 && this.deadlines.size < 200) this.deadlines.set(envelopeId, expiresAt);
45
130
  }
46
131
  receipt(receipt: DeliveryReceipt): boolean {
47
132
  if (!this.pending.has(receipt.id)) return false;
48
- if (receipt.state !== "accepted") this.pending.delete(receipt.id);
133
+ if (receipt.state !== "accepted") { this.pending.delete(receipt.id); this.waitingForReady.delete(receipt.id); }
49
134
  this.delivery(receipt); // Journal settlement precedes a state transition that may drain the next item.
50
135
  this.update();
51
136
  return true;
52
137
  }
138
+ /** Clear transport wait after the Bus records a signed outcome or durable hold. */
139
+ settled(deliveryId: string): void { this.pending.delete(deliveryId); this.waitingForReady.delete(deliveryId); this.update(); }
140
+ /** Reserve a restart-era active Store delivery before asynchronous proof validation. */
141
+ retainPending(deliveryId: string): void {
142
+ if (this.pending.has(deliveryId)) return;
143
+ this.pending.add(deliveryId);
144
+ this.waitingForReady.add(deliveryId);
145
+ this.update();
146
+ }
53
147
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
54
- if (!this.running || !this.online || !deliveryId || envs.length !== 1) throw new Error("room: unavailable");
148
+ if (!deliveryId || envs.length !== 1) throw new Error("room: unavailable");
149
+ if (!this.running || !this.online) { this.notSent(deliveryId); return; }
150
+ if (!this.ready()) { this.notSent(deliveryId, false); return; }
55
151
  const env = envs[0]!, generation = this.generation;
56
152
  const expiresAt = this.deadlines.get(env.id);
57
153
  this.deadlines.delete(env.id);
58
- const context = this.ports.context(env);
59
- const current = () => this.running && this.online && this.generation === generation && context.current();
154
+ if (expiresAt === undefined) throw new Error("room: queued deadline unavailable");
155
+ this.pending.add(deliveryId); this.update();
156
+ await this.publish(env, deliveryId, expiresAt, generation);
157
+ }
158
+ private async publish(env: Envelope, deliveryId: string, expiresAt: number, generation: number, immutable?: RoomChatEnvelope): Promise<void> {
159
+ if (this.sending.has(deliveryId)) return;
160
+ this.sending.add(deliveryId);
161
+ try { await this.publishOnce(env, deliveryId, expiresAt, generation, immutable); }
162
+ finally { this.sending.delete(deliveryId); }
163
+ }
164
+ private async publishOnce(env: Envelope, deliveryId: string, expiresAt: number, generation: number, immutable?: RoomChatEnvelope): Promise<void> {
165
+ let context: RoomBoundaryContext;
166
+ try { context = this.ports.context(env); }
167
+ catch { this.notSent(deliveryId); return; }
168
+ const trusted = () => { try { return context.current() === true; } catch { return false; } };
169
+ let readinessLost = false;
170
+ const current = () => {
171
+ const ready = this.ready();
172
+ if (!ready) readinessLost = true;
173
+ return this.pending.has(deliveryId) && this.running && this.online && this.generation === generation && ready && trusted();
174
+ };
60
175
  const result = await roomBoundary(env, { ...context, current });
61
- if (!current()) throw new Error("room: state_changed");
176
+ if (!this.pending.has(deliveryId)) return;
177
+ if (!current()) {
178
+ if (!trusted()) { this.quarantine(deliveryId, "boundary_refused"); this.receipt({ id: deliveryId, state: "needs_review", reason: "room trust changed before publication" }); }
179
+ else if (!this.running || !this.online || generation !== this.generation) this.notSent(deliveryId);
180
+ else if (!this.ready()) this.notSent(deliveryId, false);
181
+ return;
182
+ }
183
+ if (readinessLost && result.status === "refused" && trusted()) { this.notSent(deliveryId, false); return; }
62
184
  if (result.status === "refused") {
63
- this.delivery({ id: deliveryId, state: "failed_safe", reason: `room: ${result.code}` });
185
+ const reason = result.code === "not_exposed" || result.code === "invalid_message" ? result.code : "boundary_refused";
186
+ if (immutable) {
187
+ this.quarantine(deliveryId, reason);
188
+ this.receipt({ id: deliveryId, state: "needs_review", reason: `room retry refused: ${result.code}` });
189
+ } else this.receipt({ id: deliveryId, state: "failed_safe", reason: `room: ${result.code}` });
64
190
  return;
65
191
  }
66
192
  const view = readRoomOutbound(result.proof);
67
- if (!view || expiresAt === undefined || expiresAt <= (this.ports.now?.() ?? Date.now())) {
68
- this.delivery({ id: deliveryId, state: "needs_review", reason: "room queued deadline unavailable or expired" });
193
+ if (!view || expiresAt <= (this.ports.now?.() ?? Date.now())) {
194
+ if (immutable) this.quarantine(deliveryId, "boundary_refused");
195
+ this.receipt({ id: deliveryId, state: "needs_review", reason: "room queued deadline unavailable or expired" });
196
+ return;
197
+ }
198
+ if (immutable && Buffer.compare(Buffer.from(encodeRoomMessage({ v: 1, type: "chat", envelope: view })), Buffer.from(encodeRoomMessage({ v: 1, type: "chat", envelope: immutable }))) !== 0) {
199
+ this.quarantine(deliveryId, "boundary_refused");
200
+ this.receipt({ id: deliveryId, state: "needs_review", reason: "room retry privacy proof changed from immutable application" });
69
201
  return;
70
202
  }
71
- this.pending.add(deliveryId); this.update();
72
203
  try {
73
204
  await this.ports.send(view, deliveryId, expiresAt, () => current() && readRoomOutbound(result.proof) !== undefined, result.withheld);
74
- if (!current()) { this.quarantine(deliveryId); this.receipt({ id: deliveryId, state: "needs_review", reason: "room trust changed during publication" }); }
75
- } catch {
76
- this.quarantine(deliveryId);
77
- this.receipt({ id: deliveryId, state: "needs_review", reason: "room publication outcome is uncertain" });
205
+ if (!this.pending.has(deliveryId)) return;
206
+ if (!trusted() && this.running && this.online && generation === this.generation) {
207
+ this.quarantine(deliveryId, "boundary_refused"); this.receipt({ id: deliveryId, state: "needs_review", reason: "room trust changed during publication" });
208
+ return;
209
+ }
210
+ if (!this.ready()) {
211
+ let retained: boolean | undefined;
212
+ try { retained = this.ports.queued?.(deliveryId); } catch { retained = undefined; }
213
+ if (immutable || retained === true) this.waitForReady(deliveryId);
214
+ else this.notSent(deliveryId, false);
215
+ return;
216
+ }
217
+ } catch (error) {
218
+ if (!this.pending.has(deliveryId)) return;
219
+ if (error instanceof RoomNotSentError) {
220
+ let retained: boolean | undefined;
221
+ try { retained = this.ports.queued?.(deliveryId); } catch { retained = undefined; }
222
+ if (immutable || retained === true) {
223
+ if (!this.ready()) this.waitForReady(deliveryId);
224
+ } else this.notSent(deliveryId);
225
+ return;
226
+ }
227
+ if (!trusted()) {
228
+ this.quarantine(deliveryId, "boundary_refused");
229
+ this.receipt({ id: deliveryId, state: "needs_review", reason: "room trust changed during publication" });
230
+ return;
231
+ }
232
+ if (!this.running || !this.online || generation !== this.generation) return;
233
+ let retained: boolean | undefined;
234
+ try { retained = this.ports.queued?.(deliveryId); } catch { retained = undefined; }
235
+ if (!this.ready()) {
236
+ if (immutable || retained === true) this.waitForReady(deliveryId);
237
+ else this.notSent(deliveryId, false);
238
+ return;
239
+ }
240
+ if (retained !== true) {
241
+ if (retained === false && this.holdMissing(deliveryId)) {
242
+ this.pending.delete(deliveryId); this.blockedNotSent = false; this.update();
243
+ } else { this.blockedNotSent = true; this.update(); }
244
+ } // A durable intent stays active and retries on its next ACK deadline.
78
245
  }
79
246
  }
80
247
  }