@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. package/templates/packs/example.pack.json +13 -0
package/src/hub/bus.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { appendNote, DIGEST, frame, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
+ import type { DoingKind } from "./peer-doing.ts";
4
+ import type { QueuePressure } from "./queue-backpressure.ts";
3
5
  import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
6
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
5
7
  import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
8
+ import { parseRemotePeerId } from "../rooms/application.ts";
6
9
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
7
10
 
8
11
  export type { DeliveryReceipt } from "./peers.ts";
@@ -22,6 +25,10 @@ export type BusEvent =
22
25
  | { t: "state"; peer: PeerId; state: PeerState };
23
26
 
24
27
  export interface BusOptions {
28
+ /** Durable application outbox ids; the bus combines them with its own pending ids without recursion. */
29
+ remotePending?: (room: string) => readonly string[];
30
+ /** Host provenance at enqueue, never a body screen; unknown/throw marks the original private. */
31
+ remotePrivate?: (env: Readonly<Envelope>) => boolean | undefined;
25
32
  /** Live project membership; absent preserves the existing audience and delivery behaviour. */
26
33
  rostered?: (peer: PeerId) => boolean;
27
34
  /** Transient owner lifecycle fence: retain new work without changing console/budget pauses. */
@@ -37,6 +44,8 @@ export interface BusOptions {
37
44
  /** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
38
45
  condense?: (envs: Envelope[]) => Promise<Envelope[]>;
39
46
  journal?: DeliveryJournal;
47
+ /** Host-owned opt-in; no delivery body or timestamp is a routing-policy authority. */
48
+ trackQueuePressure?: () => boolean;
40
49
  /**
41
50
  * Optional: may an agent send this envelope now (issue #38)? `parent` is the id of what it answers. A reason refuses
42
51
  * it, and the sender hears it on its next delivery.
@@ -59,6 +68,8 @@ export interface BusOptions {
59
68
  * dequeued, steered or journaled for it, and the queue still waits for `Bus.pull`; a throw is swallowed.
60
69
  */
61
70
  onImportantQueued?: (peer: PeerId, counts: { queued: number; important: number }) => void;
71
+ /** Optional (#878): an attached adapter observed its session title or current plan item. Never published, journaled or tapped. */
72
+ doing?: (peer: PeerId, kind: DoingKind, text?: unknown) => void;
62
73
  }
63
74
 
64
75
  /** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
@@ -70,8 +81,16 @@ export interface BusSnapshot {
70
81
  attempts: Record<string, number>;
71
82
  withdrawn: string[];
72
83
  manualPaused?: string[];
84
+ /** Host enqueue provenance only. null means an age lost to a legacy/restored writer. */
85
+ queuedAt?: Record<string, Record<string, number | null>>;
73
86
  /** Hub notices accepted during recovery, durable but outside the carried delivery queues. */
74
87
  deferredHub?: Envelope[];
88
+ /** Local enqueue deadlines; absent old metadata is unknown, never a fresh TTL. */
89
+ remoteQueueExpires?: Record<string, Record<string, number>>;
90
+ /** Host room binding for retained queues, never reconstructed from a remote label. */
91
+ remoteQueueRooms?: Record<string, string>;
92
+ /** Original verified target identity; a reused room label cannot inherit queued text. */
93
+ remoteQueueMembers?: Record<string, string>;
75
94
  journal?: ReturnType<DeliveryJournal["snapshot"]>;
76
95
  }
77
96
 
@@ -100,6 +119,12 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
100
119
 
101
120
  const SEEN_CAP = 2048;
102
121
  const MAX_ATTEMPTS = 3;
122
+ // ponytail: provenance is capped at 2048 ids/peer, 4096 peers, 100000 ids total and 256 KiB per snapshot.
123
+ // Omitted metadata means unknown age only; use a measured per-envelope store if larger queues need provenance.
124
+ const QUEUE_TIME_CAP = 2048, QUEUE_TIME_PEERS_CAP = 4096, QUEUE_TIME_TOTAL_CAP = 100_000, QUEUE_TIME_SNAPSHOT_BYTES = 256 * 1024;
125
+ const REMOTE_ROOM_CAP = 200, REMOTE_QUEUE_TTL_MS = 24 * 60 * 60 * 1000;
126
+ const remoteId = (id: string): boolean => id.includes("@");
127
+ const remoteFingerprint = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
103
128
  // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
104
129
  // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
105
130
  const DIGEST_MAX = 10;
@@ -118,7 +143,15 @@ export class Bus {
118
143
  private readonly opts: BusOptions;
119
144
  private readonly journal?: DeliveryJournal;
120
145
  private readonly queues = new Map<PeerId, Envelope[]>();
146
+ private readonly queueTimes = new Map<PeerId, Map<string, number | null>>();
147
+ private queueTimeEntries = 0;
148
+ private queuePressureWasTracked = false;
121
149
  private readonly prefaces = new Map<PeerId, Envelope>();
150
+ private readonly remoteQueueMembers = new Map<PeerId, string>();
151
+ private readonly remoteQueueRooms = new Map<PeerId, string>();
152
+ private readonly remoteQueueExpires = new Map<PeerId, Map<string, number>>();
153
+ private readonly remoteReservations = new Map<string, Set<string>>();
154
+ private readonly remoteTransport = new Map<string, { peer: string; originals: Envelope[] }>();
122
155
  private readonly paused = new Set<PeerId>();
123
156
  private readonly manualPaused = new Set<PeerId>();
124
157
  private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
@@ -168,6 +201,7 @@ export class Bus {
168
201
  this.opts = { ...DEFAULT_BUS, ...opts };
169
202
  this.onDeliver = opts.onDeliver;
170
203
  this.journal = opts.journal;
204
+ this.trackingQueuePressure();
171
205
  if (this.journal) {
172
206
  const state = this.journal.snapshot();
173
207
  const bus = state.bus as BusSnapshot;
@@ -191,9 +225,22 @@ export class Bus {
191
225
  }
192
226
 
193
227
  add(peer: PeerAdapter): void {
228
+ if (peer.remoteRoom !== undefined && (!parseRemotePeerId(peer.id) || typeof peer.remoteRoom !== "string" || !peer.remoteRoom || peer.remoteRoom.length > 256 || !remoteFingerprint(peer.remoteMember))) throw new Error("invalid remote peer binding");
229
+ const pendingRemote = peer.remoteRoom !== undefined && this.hasRemoteWork(peer.id);
230
+ if (peer.remoteRoom !== undefined && pendingRemote) {
231
+ const room = this.remoteQueueRooms.get(peer.id), member = this.remoteQueueMembers.get(peer.id);
232
+ if (room === undefined || member === undefined) {
233
+ this.expireRemoteQueues();
234
+ if (this.hasRemoteWork(peer.id)) throw new Error("remote queued identity binding unavailable");
235
+ } else if (room !== peer.remoteRoom) throw new Error("remote queued room binding changed");
236
+ else if (member !== peer.remoteMember) throw new Error("remote queued member binding changed");
237
+ }
238
+ if (peer.remoteRoom !== undefined && !pendingRemote) { this.remoteQueueRooms.delete(peer.id); this.remoteQueueMembers.delete(peer.id); }
194
239
  this.peers.set(peer.id, peer);
195
240
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
196
241
  peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
242
+ peer.onDoing = (kind, text) => { if (!this.closed && this.peers.get(peer.id) === peer) this.opts.doing?.(peer.id, kind, text); };
243
+ this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
197
244
  peer.onMessage = (text, opts) => {
198
245
  if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
199
246
  const { priority, body } = parseMarker(text);
@@ -208,6 +255,12 @@ export class Bus {
208
255
  const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
209
256
  const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
210
257
  let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
258
+ try { this.checkRemotePublication(env); }
259
+ catch (error) {
260
+ if (!(error instanceof PreEffectToolRefusal)) throw error;
261
+ this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${error.message}`));
262
+ return error.message;
263
+ }
211
264
  const offRoster = this.offRosterRecipients(env);
212
265
  if (offRoster.length && offRoster.length === new Set(env.to?.filter(id => id !== env.from)).size) {
213
266
  const reason = `off roster: ${offRoster.join(", ")}`;
@@ -233,7 +286,13 @@ export class Bus {
233
286
  this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${refused}`));
234
287
  return refused;
235
288
  }
236
- const published = this.publishResult(env);
289
+ let published: PublishResult;
290
+ try { published = this.publishResult(env); }
291
+ catch (error) {
292
+ if (!(error instanceof PreEffectToolRefusal)) throw error;
293
+ this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${error.message}`));
294
+ return error.message;
295
+ }
237
296
  if (offRoster.length && published.dropped !== "hop") this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
238
297
  // The sender's result (issue #107): a turn answer has no caller, so it hears it on its next delivery. It counts as
239
298
  // not sent only when nobody got it.
@@ -267,10 +326,11 @@ export class Bus {
267
326
 
268
327
  private persist(): void {
269
328
  if (!this.journal) return;
329
+ const tracking = this.trackingQueuePressure();
270
330
  try {
271
331
  this.journal.transaction(() => {
272
332
  for (const outcome of this.pendingOutcomes) this.journal!.createDelivery(outcome);
273
- this.journal!.persistBus(this.snapshotWithoutJournal(), [...this.manualPaused]);
333
+ this.journal!.persistBus(this.snapshotWithoutJournal(), [...this.manualPaused], tracking);
274
334
  });
275
335
  this.pendingOutcomes.length = 0;
276
336
  this.storageError = undefined;
@@ -281,7 +341,7 @@ export class Bus {
281
341
  private durableHandoff(peer: PeerId, deliveryId: string, originals: Envelope[], out: Envelope[], observe = true): void {
282
342
  if (!this.journal) return;
283
343
  const delivery = { id: deliveryId, peer, state: "dispatching" as const, createdAt: Date.now(), originals, out };
284
- this.journal.checkpointDelivery(this.snapshotWithoutJournal(), delivery);
344
+ this.journal.checkpointDelivery(this.snapshotWithoutJournal(), delivery, this.trackingQueuePressure());
285
345
  if (observe) this.memoryDelivery(peer, deliveryId, out, "rendered", originals);
286
346
  }
287
347
 
@@ -375,16 +435,39 @@ export class Bus {
375
435
 
376
436
  /** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
377
437
  snapshot(includeJournal = true): BusSnapshot {
438
+ const rooms = this.remoteRoomSnapshot(), members = this.remoteMemberSnapshot();
439
+ const queues = Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))]));
440
+ for (const peer of new Set([...Object.keys(rooms), ...Object.keys(members)])) queues[peer] ??= [];
378
441
  const out: BusSnapshot = {
379
442
  schemaVersion: 1,
380
- queues: Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))])),
443
+ queues,
381
444
  prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
382
445
  seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
383
446
  attempts: Object.fromEntries(this.attempts),
384
447
  withdrawn: [...this.withdrawn],
385
448
  manualPaused: [...this.manualPaused],
386
449
  ...(this.deferredHub.size ? { deferredHub: structuredClone([...this.deferredHub.values()]) } : {}),
450
+ ...(this.remoteQueueExpires.size ? { remoteQueueExpires: this.remoteExpirySnapshot() } : {}),
451
+ ...(Object.keys(rooms).length ? { remoteQueueRooms: rooms } : {}),
452
+ ...(Object.keys(members).length ? { remoteQueueMembers: members } : {}),
387
453
  };
454
+ if (this.trackingQueuePressure()) {
455
+ // Metadata is advisory: omit capped or unknown entries rather than enlarge a delivery without bound.
456
+ const queuedAt = Object.create(null) as NonNullable<BusSnapshot["queuedAt"]>;
457
+ let bytes = 32;
458
+ for (const [peer, queue] of this.queues) {
459
+ const times = Object.create(null) as Record<string, number>;
460
+ const peerBytes = Buffer.byteLength(JSON.stringify(peer)) + 4;
461
+ for (const env of queue) {
462
+ const at = this.queueTimes.get(peer)?.get(env.id);
463
+ const entryBytes = Buffer.byteLength(JSON.stringify(env.id)) + 20;
464
+ if (typeof at !== "number" || bytes + peerBytes + entryBytes > QUEUE_TIME_SNAPSHOT_BYTES) continue;
465
+ times[env.id] = at; bytes += entryBytes;
466
+ }
467
+ if (Object.keys(times).length) { queuedAt[peer] = times; bytes += peerBytes; }
468
+ }
469
+ if (Object.keys(queuedAt).length) out.queuedAt = queuedAt;
470
+ }
388
471
  if (includeJournal && this.journal) out.journal = this.journal.snapshot();
389
472
  return out;
390
473
  }
@@ -395,7 +478,7 @@ export class Bus {
395
478
  if (_operationId && this.journal) {
396
479
  const current = this.journal.snapshot();
397
480
  if (current.revision === 0) {
398
- this.journal.importSnapshot(snapshot.journal ?? { schemaVersion: 1, revision: 1, instanceId: this.journal.instanceId, bus: snapshot, deliveries: [], manualPaused: snapshot.manualPaused ?? [] }, _operationId);
481
+ this.journal.importSnapshot(snapshot.journal ?? { schemaVersion: 1, revision: 1, instanceId: this.journal.instanceId, bus: snapshot, deliveries: [], manualPaused: snapshot.manualPaused ?? [] }, _operationId, this.trackingQueuePressure());
399
482
  }
400
483
  const committedQueues = snapshot.queues;
401
484
  snapshot = this.journal.snapshot().bus;
@@ -417,6 +500,24 @@ export class Bus {
417
500
  for (const env of deferred) this.deferredHub.set(env.id, structuredClone(env));
418
501
  this.queues.clear();
419
502
  for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
503
+ this.queueTimes.clear(); this.queueTimeEntries = 0;
504
+ if (this.trackingQueuePressure()) for (const [peer, queue] of this.queues) {
505
+ const provenance = snapshot.queuedAt;
506
+ const times = provenance && typeof provenance === "object" && !Array.isArray(provenance) && Object.hasOwn(provenance, peer) ? provenance[peer] : undefined;
507
+ for (const env of queue) {
508
+ const at = times && typeof times === "object" && !Array.isArray(times) && Object.hasOwn(times, env.id) ? times[env.id] : null;
509
+ this.recordQueueTime(peer, env.id, typeof at === "number" && Number.isSafeInteger(at) && at > 0 ? at : null);
510
+ }
511
+ }
512
+ this.remoteQueueMembers.clear();
513
+ for (const [peer, member] of Object.entries(snapshot.remoteQueueMembers ?? {})) if (parseRemotePeerId(peer) && remoteFingerprint(member)) this.remoteQueueMembers.set(peer, member);
514
+ this.remoteQueueRooms.clear();
515
+ for (const [peer, room] of Object.entries(snapshot.remoteQueueRooms ?? {})) if (parseRemotePeerId(peer) && typeof room === "string" && room.length > 0 && room.length <= 256) this.remoteQueueRooms.set(peer, room);
516
+ this.remoteQueueExpires.clear();
517
+ for (const [peer, values] of Object.entries(snapshot.remoteQueueExpires ?? {})) {
518
+ if (!remoteId(peer)) continue;
519
+ this.remoteQueueExpires.set(peer, new Map(Object.entries(values).filter(([, expiry]) => Number.isSafeInteger(expiry) && expiry >= 0)));
520
+ }
420
521
  this.prefaces.clear();
421
522
  for (const [id, e] of Object.entries(snapshot.prefaces ?? {})) this.prefaces.set(id, { ...e, body: uniqueMemoryLines(e.body), ...(e.to ? { to: [...e.to] } : {}) });
422
523
  this.seen.clear();
@@ -460,6 +561,7 @@ export class Bus {
460
561
  }
461
562
 
462
563
  private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
564
+ if (remoteId(peer)) return out;
463
565
  if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
464
566
  return out.map((env, index) => {
465
567
  if (index !== 0) return env;
@@ -474,6 +576,7 @@ export class Bus {
474
576
 
475
577
  resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
476
578
  if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
579
+ this.trackingQueuePressure();
477
580
  const record = this.queueShow(id);
478
581
  if (!record) throw new Error("unknown delivery");
479
582
  const previous = this.journal.resolution(id);
@@ -481,6 +584,13 @@ export class Bus {
481
584
  this.journal.resolve(id, revision, action, reason, revision, actor); // verifies identical request; no second enqueue
482
585
  return;
483
586
  }
587
+ if (action === "retry" && remoteId(record.peer)) {
588
+ const peer = this.peers.get(record.peer), room = this.remoteQueueRooms.get(record.peer), member = this.remoteQueueMembers.get(record.peer);
589
+ if (record.state !== "needs_review" || record.originals.length !== 1 || !room || !remoteFingerprint(member) || peer?.remoteRoom !== room || peer.remoteMember !== member) throw new Error("remote original identity unavailable or changed; discard it or send a new message explicitly");
590
+ const deadline = this.remoteQueueExpires.get(record.peer)?.get(record.originals[0]!.id);
591
+ if (!Number.isSafeInteger(deadline) || deadline! <= Date.now()) throw new PreEffectToolRefusal("remote original deadline unavailable or expired; discard it or send a new message explicitly");
592
+ this.checkRemotePublication(record.originals[0]!);
593
+ }
484
594
  const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
485
595
  // #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
486
596
  // discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
@@ -489,7 +599,7 @@ export class Bus {
489
599
  if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
490
600
  if (record.revision !== revision) throw new Error("stale delivery revision");
491
601
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
492
- if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
602
+ if (action === "retry" && !remoteId(record.peer) && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
493
603
  const queue = this.queues.get(record.peer) ?? [];
494
604
  const hadQueue = this.queues.has(record.peer);
495
605
  const before = [...queue];
@@ -549,8 +659,22 @@ export class Bus {
549
659
  if (this.closed) return;
550
660
  const owner = this.activeDeliveries.get(receipt.id);
551
661
  if (!owner || owner !== peer || !this.journal) return;
662
+ this.trackingQueuePressure();
552
663
  const id = receipt.id;
553
664
  const before = this.journal.get(id);
665
+ if (this.peers.get(peer)?.remoteRoom !== undefined && receipt.state === "failed_safe") {
666
+ // A signed recipient NACK is a terminal known refusal, not a failed native
667
+ // turn eligible for poison retries. It records no recipient read evidence.
668
+ this.journal.transition(id, "failed", receipt.reason ?? "remote recipient refused safely");
669
+ this.activeDeliveries.delete(id); this.remoteTransport.delete(id);
670
+ for (const env of before?.originals ?? []) { this.attempts.delete(`${peer}:${env.id}`); this.remoteQueueExpires.get(peer)?.delete(env.id); }
671
+ const last = this.lastDelivery.get(peer);
672
+ if (before && last && last.out.length === before.out.length && last.out.every((env, index) => env.id === before.out[index]!.id)) this.lastDelivery.delete(peer);
673
+ this.refreshRecoveryHold(peer);
674
+ this.onQueues?.();
675
+ void this.drain(peer);
676
+ return;
677
+ }
554
678
  const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
555
679
  if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
556
680
  if (state === "completed") this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
@@ -578,6 +702,7 @@ export class Bus {
578
702
 
579
703
  completeReply(peer: PeerId, envelopeId: string): void {
580
704
  if (!this.journal) return;
705
+ this.trackingQueuePressure();
581
706
  for (const [id, owner] of this.activeDeliveries) {
582
707
  if (owner !== peer) continue;
583
708
  const row = this.journal.get(id);
@@ -654,6 +779,7 @@ export class Bus {
654
779
 
655
780
  /** Context the hub wants the peer to see once: it rides in front of the next delivery instead of costing a turn of its own. */
656
781
  preface(id: PeerId, text: string): void {
782
+ if (remoteId(id)) return;
657
783
  const body = uniqueMemoryLines(quoteNotes(text));
658
784
  const existing = this.prefaces.get(id);
659
785
  if (existing) {
@@ -669,6 +795,7 @@ export class Bus {
669
795
 
670
796
  /** Another peer's shared note: rides in the preface like recall. The newest NOTES_KEPT stay; older ones remain in amem. */
671
797
  note(id: PeerId, line: string): void {
798
+ if (remoteId(id)) return;
672
799
  const existing = this.prefaces.get(id);
673
800
  this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
674
801
  this.persist();
@@ -676,6 +803,7 @@ export class Bus {
676
803
 
677
804
  /** A preface that went out with a failed delivery comes back ahead of whatever was prefaced meanwhile, instead of replacing it. */
678
805
  private restorePreface(id: PeerId, env: Envelope): void {
806
+ if (remoteId(id)) return;
679
807
  const now = this.prefaces.get(id);
680
808
  this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
681
809
  }
@@ -687,6 +815,7 @@ export class Bus {
687
815
  */
688
816
  discardDeferredHub(reason: string): string[] {
689
817
  if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
818
+ this.trackingQueuePressure();
690
819
  const pending = [...this.deferredHub];
691
820
  const settled: string[] = [];
692
821
  try {
@@ -713,6 +842,137 @@ export class Bus {
713
842
  return [...(this.queues.get(peer) ?? []), ...[...this.deferredHub.values()].filter(e => e.to?.includes(peer))];
714
843
  }
715
844
 
845
+ /** Own accepted queue/preparation/active ids, without consulting the host callback. */
846
+ remotePendingIds(room: string): string[] {
847
+ const ids = new Set<string>();
848
+ for (const [peer, queue] of this.queues) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of queue) ids.add(env.id);
849
+ for (const [peer, batch] of this.preparing) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of batch) ids.add(env.id);
850
+ for (const { peer, originals } of this.remoteTransport.values()) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of originals) ids.add(env.id);
851
+ for (const [id, peer] of this.activeDeliveries) if ((this.remoteQueueRooms.get(peer) ?? this.peers.get(peer)?.remoteRoom) === room) for (const env of this.journal?.get(id)?.originals ?? []) ids.add(env.id);
852
+ return [...ids];
853
+ }
854
+ /** Remote preflight precedes limiter admission; publication rechecks before effects. */
855
+ checkRemotePublication(env: Envelope): string[] {
856
+ if (env.to?.some(remoteId) && this.opts.remotePrivate) {
857
+ try { if (this.opts.remotePrivate(env) !== false) env.private = true; }
858
+ catch { env.private = true; }
859
+ }
860
+ const rooms = new Set<string>(), targets = [...new Set((env.to ?? []).filter(remoteId))];
861
+ if (remoteId(env.from) && !parseRemotePeerId(env.from)) throw new PreEffectToolRefusal("invalid remote sender");
862
+ for (const target of targets) {
863
+ const peer = this.peers.get(target);
864
+ if (!parseRemotePeerId(target) || !peer?.remoteRoom || !remoteFingerprint(peer.remoteMember)) throw new PreEffectToolRefusal("remote recipient unavailable");
865
+ const bound = this.remoteQueueRooms.get(target), member = this.remoteQueueMembers.get(target);
866
+ if (this.hasRemoteWork(target) && (!bound || !member || bound !== peer.remoteRoom || member !== peer.remoteMember)) throw new PreEffectToolRefusal("remote queued identity binding changed");
867
+ const ttl = peer.remoteQueueTtlMs ?? REMOTE_QUEUE_TTL_MS;
868
+ if (!Number.isSafeInteger(ttl) || ttl <= 0 || !Number.isSafeInteger(Date.now() + ttl)) throw new PreEffectToolRefusal("remote queue TTL unavailable");
869
+ rooms.add(peer.remoteRoom);
870
+ }
871
+ // ponytail: one original has one stable wire identity. A follow-up may derive
872
+ // stable per-target wire ids and account each delivery separately before fan-out.
873
+ if (targets.length > 1) throw new PreEffectToolRefusal(`remote targets ${targets.slice(0, 64).join(", ")}${targets.length > 64 ? ` and ${targets.length - 64} more` : ""}: send to each remote peer separately`);
874
+ for (const room of rooms) {
875
+ const pending = new Set([...this.remotePendingIds(room), ...(this.remoteReservations.get(room) ?? [])]);
876
+ let external: readonly string[];
877
+ try { external = this.opts.remotePending?.(room) ?? []; } catch { throw new PreEffectToolRefusal("remote queue unavailable"); }
878
+ if (!Array.isArray(external) || external.length > REMOTE_ROOM_CAP || external.some(id => typeof id !== "string" || id.length < 1 || id.length > 256)) throw new PreEffectToolRefusal("remote queue unavailable");
879
+ for (const id of external) pending.add(id);
880
+ pending.add(env.id);
881
+ if (pending.size > REMOTE_ROOM_CAP) throw new PreEffectToolRefusal("remote room queue is full");
882
+ }
883
+ return [...rooms];
884
+ }
885
+ private hasRemoteWork(peer: string, pending?: ReadonlySet<string>): boolean {
886
+ return (this.queues.get(peer)?.length ?? 0) > 0 || this.preparing.has(peer) || [...this.activeDeliveries.values()].includes(peer) || [...this.remoteTransport.values()].some(value => value.peer === peer) || (pending ?? new Set(this.journal?.pendingPeers() ?? [])).has(peer);
887
+ }
888
+ private remoteBindingSnapshot(values: ReadonlyMap<string, string>): Record<string, string> {
889
+ if (!values.size) return {};
890
+ const pending = new Set(this.journal?.pendingPeers() ?? []);
891
+ return Object.fromEntries([...values].filter(([peer]) => this.hasRemoteWork(peer, pending)));
892
+ }
893
+ private remoteMemberSnapshot(): Record<string, string> { return this.remoteBindingSnapshot(this.remoteQueueMembers); }
894
+ private remoteRoomSnapshot(): Record<string, string> { return this.remoteBindingSnapshot(this.remoteQueueRooms); }
895
+ private remoteExpirySnapshot(): Record<string, Record<string, number>> {
896
+ const snapshot: Record<string, Record<string, number>> = {};
897
+ for (const [peer, expiries] of this.remoteQueueExpires) {
898
+ const pending = new Set([...(this.queues.get(peer) ?? []), ...(this.preparing.get(peer) ?? [])].map(env => env.id));
899
+ for (const { peer: target, originals } of this.remoteTransport.values()) if (target === peer) for (const env of originals) pending.add(env.id);
900
+ for (const [id, target] of this.activeDeliveries) if (target === peer) for (const env of this.journal?.get(id)?.originals ?? []) pending.add(env.id);
901
+ // Uncertainty is still pending work. Keep its known TTL through checkpoints,
902
+ // using unresolved rows only, never completed delivery history.
903
+ for (const row of this.journal?.unresolved(peer) ?? []) for (const env of row.originals) pending.add(env.id);
904
+ for (const id of expiries.keys()) if (!pending.has(id)) expiries.delete(id);
905
+ if (expiries.size) snapshot[peer] = Object.fromEntries(expiries);
906
+ else this.remoteQueueExpires.delete(peer);
907
+ }
908
+ return snapshot;
909
+ }
910
+ /** Metadata-only timer hint; an unknown retained deadline/binding is due immediately. */
911
+ nextRemoteExpiry(): number | undefined {
912
+ let next: number | undefined;
913
+ for (const [peer, queue] of this.queues) {
914
+ if (!remoteId(peer) || !queue.length) continue;
915
+ if (!this.remoteQueueRooms.has(peer) || !this.remoteQueueMembers.has(peer)) return Date.now();
916
+ const expiries = this.remoteQueueExpires.get(peer);
917
+ for (const env of queue) {
918
+ const expiry = expiries?.get(env.id);
919
+ if (expiry === undefined) return Date.now();
920
+ next = next === undefined ? expiry : Math.min(next, expiry);
921
+ }
922
+ }
923
+ return next;
924
+ }
925
+ /** Expiry is a durable uncertain outcome, never a silent retry/drop or a fresh timestamp. */
926
+ expireRemoteQueues(now = Date.now()): number {
927
+ if (!Number.isSafeInteger(now) || now < 0) throw new Error("invalid remote queue clock");
928
+ if (!this.journal || this.closed) return 0; // Without a journal there is no durable sink for uncertain work.
929
+ const expired = [...this.queues].filter(([peer]) => remoteId(peer)).flatMap(([peer, queue]) => queue.filter(env => {
930
+ const expiry = this.remoteQueueExpires.get(peer)?.get(env.id);
931
+ return this.remoteQueueRooms.get(peer) === undefined || this.remoteQueueMembers.get(peer) === undefined || expiry === undefined || expiry <= now;
932
+ }).map(env => ({ peer, env })));
933
+ if (!expired.length) return 0;
934
+ const queues = new Map([...this.queues].map(([peer, queue]) => [peer, [...queue]]));
935
+ const expiries = new Map([...this.remoteQueueExpires].map(([peer, values]) => [peer, new Map(values)]));
936
+ const held = [...this.recoveryHeldPeers], outcomes = this.pendingOutcomes.length;
937
+ try {
938
+ for (const { peer, env } of expired) {
939
+ const queue = this.queues.get(peer)!;
940
+ const index = queue.findIndex(item => item.id === env.id);
941
+ if (index >= 0) queue.splice(index, 1);
942
+ const known = this.remoteQueueExpires.get(peer)?.has(env.id);
943
+ this.remoteQueueExpires.get(peer)?.delete(env.id);
944
+ this.pendingOutcomes.push({ id: crypto.randomUUID(), peer, state: "needs_review", createdAt: now, originals: [env], out: [env], reason: !known ? "remote queue expiry unavailable after restore" : !this.remoteQueueRooms.has(peer) ? "remote queue room binding unavailable after restore" : !this.remoteQueueMembers.has(peer) ? "remote queue member binding unavailable after restore" : "remote queue expired before recipient admission" });
945
+ this.recoveryHeldPeers.add(peer);
946
+ }
947
+ this.persist();
948
+ } catch (error) {
949
+ for (const [peer, before] of queues) this.queues.get(peer)!.splice(0, this.queues.get(peer)!.length, ...before);
950
+ this.remoteQueueExpires.clear(); for (const [peer, values] of expiries) this.remoteQueueExpires.set(peer, values);
951
+ this.recoveryHeldPeers.clear(); for (const peer of held) this.recoveryHeldPeers.add(peer);
952
+ this.pendingOutcomes.length = outcomes;
953
+ throw error;
954
+ }
955
+ this.onQueues?.();
956
+ return expired.length;
957
+ }
958
+ /** A room may acknowledge local admission only with a live, known-available journal. */
959
+ deliveryJournalAvailable(): boolean { return this.journal !== undefined && !this.storageError && !this.closed; }
960
+ /** Explicit person disposition metadata only, never inferred from transport completion. */
961
+ remoteDisposition(deliveryId: string): "completed" | "retry" | "discard" | undefined {
962
+ const resolution = this.journal?.resolution(deliveryId);
963
+ return resolution?.actor === "user" ? resolution.action : undefined;
964
+ }
965
+ /** A late signed queue receipt is evidence for the person; it never auto-settles uncertainty. */
966
+ noteLateRemoteReceipt(peer: string, deliveryId: string, outcome: "completed" | "failed_safe"): boolean {
967
+ if ((outcome !== "completed" && outcome !== "failed_safe") || !parseRemotePeerId(peer) || !this.peers.get(peer)?.remoteRoom || !this.journal || this.storageError || this.closed) return false;
968
+ const row = this.journal.get(deliveryId), adapter = this.peers.get(peer)!;
969
+ if (!row || row.peer !== peer || row.state !== "needs_review" || this.remoteQueueRooms.get(peer) !== adapter.remoteRoom || this.remoteQueueMembers.get(peer) !== adapter.remoteMember) return false;
970
+ const reason = outcome === "completed" ? "late signed completed receipt: remote queue admitted; remains needs_review" : "late signed failed_safe receipt; remains needs_review";
971
+ if (row.reason !== reason) this.journal.transition(row.id, "needs_review", reason, row.revision);
972
+ this.onQueues?.();
973
+ return true;
974
+ }
975
+
716
976
  /** Returns the peers the envelope was accepted for, including durable deferred notices. */
717
977
  publish(env: Envelope): PeerId[] {
718
978
  return this.publishResult(env).targets;
@@ -722,6 +982,7 @@ export class Bus {
722
982
  publishResult(env: Envelope): PublishResult {
723
983
  if (this.storageError) throw new Error("delivery journal unavailable");
724
984
  if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return { targets: [] };
985
+ this.checkRemotePublication(env);
725
986
  if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
726
987
  if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
727
988
  const targets = this.audience(env);
@@ -737,48 +998,52 @@ export class Bus {
737
998
  private publishNow(env: Envelope): PublishResult {
738
999
  if (this.storageError) throw new Error("delivery journal unavailable");
739
1000
  if (this.seen.has(env.id)) return { targets: [] };
740
- this.seen.set(env.id, env);
741
- if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
742
-
743
- // fyi is for the record (console now, task board from M4), never worth an agent's turn.
744
- const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
745
- this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
746
- if (dropped) { this.persist(); return { targets: [], dropped }; }
747
-
748
- const hushed = this.hushed(env);
749
- if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
750
- const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id) || !this.isRostered(id));
751
- this.suppressDrain = !!this.journal;
752
- try { for (const id of targets) {
753
- const peer = this.peers.get(id);
754
- if (!peer) { this.enqueue(id, env); continue; }
755
- if (!this.journal && !this.held(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
756
- // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
757
- this.steering++;
758
- const deliveryId = crypto.randomUUID();
759
- this.activeDeliveries.set(deliveryId, id);
760
- if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
761
- const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
762
- steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
763
- this.deliveryFailed(id);
764
- if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
765
- this.activeDeliveries.delete(deliveryId);
766
- if (!this.journal) this.enqueue(id, env, true);
767
- }).finally(() => { this.steering--; });
768
- } else this.enqueue(id, env);
769
- } } finally { this.suppressDrain = false; }
770
- if (this.journal) {
771
- this.persist();
772
- if (!this.flushingHub) this.onQueues?.();
773
- for (const id of targets) {
1001
+ const rooms = this.checkRemotePublication(env);
1002
+ for (const room of rooms) { const reserved = this.remoteReservations.get(room) ?? new Set<string>(); reserved.add(env.id); this.remoteReservations.set(room, reserved); }
1003
+ try {
1004
+ this.seen.set(env.id, env);
1005
+ if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
1006
+
1007
+ // fyi is for the record (console now, task board from M4), never worth an agent's turn.
1008
+ const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
1009
+ this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
1010
+ if (dropped) { this.persist(); return { targets: [], dropped }; }
1011
+
1012
+ const hushed = this.hushed(env);
1013
+ if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
1014
+ const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id) || !this.isRostered(id));
1015
+ this.suppressDrain = !!this.journal;
1016
+ try { for (const id of targets) {
774
1017
  const peer = this.peers.get(id);
775
- if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
776
- else void this.drain(id);
1018
+ if (!peer) { this.enqueue(id, env); continue; }
1019
+ if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy") {
1020
+ // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
1021
+ this.steering++;
1022
+ const deliveryId = crypto.randomUUID();
1023
+ this.activeDeliveries.set(deliveryId, id);
1024
+ if (this.journal) this.durableHandoff(id, deliveryId, [env], [env]);
1025
+ const steerResult = this.journal ? peer.steer([env], deliveryId) : peer.steer([env]);
1026
+ steerResult.then(() => { this.delivered(id, [env]); }).catch(() => {
1027
+ this.deliveryFailed(id);
1028
+ if (this.journal) this.journal.transition(deliveryId, "needs_review", "adapter steering outcome is uncertain");
1029
+ this.activeDeliveries.delete(deliveryId);
1030
+ if (!this.journal) this.enqueue(id, env, true);
1031
+ }).finally(() => { this.steering--; });
1032
+ } else this.enqueue(id, env);
1033
+ } } finally { this.suppressDrain = false; }
1034
+ if (this.journal) {
1035
+ this.persist();
1036
+ if (!this.flushingHub) this.onQueues?.();
1037
+ for (const id of targets) {
1038
+ const peer = this.peers.get(id);
1039
+ if (!this.held(id) && env.priority === "important" && !remoteId(id) && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
1040
+ else void this.drain(id);
1041
+ }
777
1042
  }
778
- }
779
- const offRoster = targets.filter(id => !this.isRostered(id));
780
- if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
781
- return { targets };
1043
+ const offRoster = targets.filter(id => !this.isRostered(id));
1044
+ if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
1045
+ return { targets };
1046
+ } finally { for (const room of rooms) { const reserved = this.remoteReservations.get(room); reserved?.delete(env.id); if (!reserved?.size) this.remoteReservations.delete(room); } }
782
1047
  }
783
1048
 
784
1049
  isRostered(peer: PeerId): boolean { return this.opts.rostered?.(peer) ?? true; }
@@ -798,7 +1063,7 @@ export class Bus {
798
1063
  /** Who an envelope is queued for: its `to`, or every peer but its sender. */
799
1064
  audience(env: Envelope): PeerId[] {
800
1065
  const known = new Set([...this.peers.keys(), ...this.queues.keys(), ...(this.journal?.list().map((d) => d.peer) ?? [])]);
801
- return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && (known.has(id) || !this.isRostered(id)) && (env.to !== undefined || this.isRostered(id)));
1066
+ return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && (known.has(id) || !this.isRostered(id)) && (env.to !== undefined || this.isRostered(id)) && (env.to !== undefined || !remoteId(id)));
802
1067
  }
803
1068
 
804
1069
  /** The recipients of `env` that the silence policy holds it back from, each with why (issue #107). */
@@ -875,7 +1140,7 @@ export class Bus {
875
1140
  }
876
1141
 
877
1142
  private memoryDelivery(peer: PeerId, id: string, out: Envelope[], field: "rendered" | "delivered", originals: Envelope[] = out): void {
878
- if ([...out, ...originals].some(env => env.private)) return;
1143
+ if (remoteId(peer) || [...out, ...originals].some(env => env.private || remoteId(env.from) || env.to?.some(remoteId))) return;
879
1144
  try { this.onMemoryDelivery?.(peer, id, out, field); } catch { /* optional telemetry never alters delivery */ }
880
1145
  }
881
1146
 
@@ -896,6 +1161,54 @@ export class Bus {
896
1161
  return this.queues.get(id)?.length ?? 0;
897
1162
  }
898
1163
 
1164
+ /** New automatic offers use only bounded pending metadata, never queue/history bodies. */
1165
+ assignmentQueue(peer: PeerId): QueuePressure {
1166
+ const queued = this.queued(peer), tracking = this.trackingQueuePressure();
1167
+ if (this.storageError) return { queued };
1168
+ if (queued === 0) return { queued: 0, oldestQueuedAt: null };
1169
+ if (!tracking) return { queued };
1170
+ // A durable summary cannot revive provenance that this host discarded while tracking was off.
1171
+ const times = this.queueTimes.get(peer);
1172
+ let oldest: number | undefined;
1173
+ for (const env of this.queues.get(peer) ?? []) {
1174
+ const at = times?.get(env.id);
1175
+ if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return { queued };
1176
+ oldest = oldest === undefined ? at : Math.min(oldest, at);
1177
+ }
1178
+ if (this.journal) {
1179
+ try {
1180
+ const saved = this.journal.pendingQueueMetadata(peer);
1181
+ return saved?.queued === queued ? saved : { queued };
1182
+ } catch { return { queued }; }
1183
+ }
1184
+ return oldest === undefined ? { queued } : { queued, oldestQueuedAt: oldest };
1185
+ }
1186
+
1187
+ private trackingQueuePressure(): boolean {
1188
+ let tracking = false;
1189
+ try { tracking = this.opts.trackQueuePressure?.() === true; } catch { /* unknown policy is not enabled */ }
1190
+ if (!tracking && this.queuePressureWasTracked) { this.queueTimes.clear(); this.queueTimeEntries = 0; }
1191
+ this.queuePressureWasTracked = tracking;
1192
+ this.journal?.setQueueMetadataTracking(tracking);
1193
+ return tracking;
1194
+ }
1195
+
1196
+ private recordQueueTime(peer: PeerId, id: string, at: number | null): void {
1197
+ if (!this.trackingQueuePressure()) return;
1198
+ let times = this.queueTimes.get(peer);
1199
+ if (!times) {
1200
+ if (this.queueTimes.size >= QUEUE_TIME_PEERS_CAP) this.dropQueueTimes(this.queueTimes.keys().next().value!);
1201
+ this.queueTimes.set(peer, times = new Map());
1202
+ }
1203
+ if (!times.has(id)) { times.set(id, at); this.queueTimeEntries++; }
1204
+ while (times.size > QUEUE_TIME_CAP) { times.delete(times.keys().next().value!); this.queueTimeEntries--; }
1205
+ while (this.queueTimeEntries > QUEUE_TIME_TOTAL_CAP) this.dropQueueTimes(this.queueTimes.keys().next().value!);
1206
+ }
1207
+ private dropQueueTimes(peer: PeerId): void {
1208
+ this.queueTimeEntries -= this.queueTimes.get(peer)?.size ?? 0;
1209
+ this.queueTimes.delete(peer);
1210
+ }
1211
+
899
1212
  /**
900
1213
  * Queued envelopes that skip the batch window (issue #41: `queued 3` hid which ones the peer still waits out).
901
1214
  * An `important` envelope that is in a queue is one that could not be steered, so it does not interrupt a
@@ -930,8 +1243,14 @@ export class Bus {
930
1243
  const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
931
1244
  const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
932
1245
  let queuedEnv = preparing ? { ...env, ts: Math.min(preparing.ts, env.ts) } : env;
1246
+ const clock = this.trackingQueuePressure() ? Date.now() : 0;
1247
+ let enteredAt = this.queueTimes.get(id)?.has(env.id) ? this.queueTimes.get(id)!.get(env.id)!
1248
+ : front || this.flushingHub || !Number.isSafeInteger(clock) || clock <= 0 ? null : clock;
1249
+ if (this.trackingQueuePressure() && !this.queueTimes.get(id)?.has(env.id) && queue.some(old => old.id === env.id)) enteredAt = null;
1250
+ if (preparing) enteredAt = this.queueTimes.get(id)?.get(preparing.id) ?? null;
933
1251
  if (replace >= 0) {
934
1252
  const old = queue[replace]!;
1253
+ enteredAt = this.queueTimes.get(id)?.get(old.id) ?? null;
935
1254
  // Keep the first milestone's deadline, so continuous updates cannot create another batching window.
936
1255
  queuedEnv = { ...queuedEnv, ts: Math.min(old.ts, queuedEnv.ts) };
937
1256
  queue[replace] = queuedEnv;
@@ -939,16 +1258,26 @@ export class Bus {
939
1258
  this.discardQueued(id, old, "superseded supervision milestone");
940
1259
  } else if (front) queue.unshift(queuedEnv);
941
1260
  else queue.push(queuedEnv);
1261
+ this.recordQueueTime(id, queuedEnv.id, enteredAt);
942
1262
  if (preparing && !this.withdrawn.has(preparing.id)) {
943
1263
  this.markWithdrawn(preparing.id);
944
1264
  this.discardQueued(id, preparing, "superseded supervision milestone during preparation");
945
1265
  }
946
- if (queue.length > this.opts.queueCap) {
1266
+ if (!remoteId(id) && queue.length > this.opts.queueCap) {
947
1267
  const victim = queue.findIndex((e) => e.priority !== "important");
948
1268
  const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
949
1269
  if (this.journal) this.pendingOutcomes.push({ id: crypto.randomUUID(), peer: id, state: "failed", createdAt: Date.now(), originals: [lost!], out: [], reason: "queue capacity exceeded" });
950
1270
  this.emit({ t: "overflow", env: lost!, peer: id });
951
1271
  }
1272
+ if (remoteId(id) && !front) {
1273
+ const room = this.peers.get(id)?.remoteRoom;
1274
+ if (room !== undefined) this.remoteQueueRooms.set(id, room);
1275
+ const member = this.peers.get(id)?.remoteMember;
1276
+ if (member !== undefined) this.remoteQueueMembers.set(id, member);
1277
+ const expiries = this.remoteQueueExpires.get(id) ?? new Map<string, number>();
1278
+ if (!expiries.has(env.id)) expiries.set(env.id, Date.now() + (this.peers.get(id)?.remoteQueueTtlMs ?? REMOTE_QUEUE_TTL_MS));
1279
+ this.remoteQueueExpires.set(id, expiries);
1280
+ }
952
1281
  if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
953
1282
  // Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
954
1283
  const counts = { queued: queue.length, important: queue.filter((e) => e.priority === "important").length };
@@ -1009,7 +1338,9 @@ export class Bus {
1009
1338
 
1010
1339
  /** A push may go out now: nothing holds the queue, the peer is idle, and its session shows pushes (issue #205). */
1011
1340
  private deliverable(id: PeerId): boolean {
1012
- return !this.held(id) && this.stateOf(id) === "idle" && !this.peers.get(id)?.pullOnly;
1341
+ const peer = this.peers.get(id);
1342
+ const remoteBinding = !remoteId(id) || !!peer?.remoteRoom && this.remoteQueueRooms.get(id) === peer.remoteRoom && this.remoteQueueMembers.get(id) === peer.remoteMember;
1343
+ return remoteBinding && !this.held(id) && this.stateOf(id) === "idle" && !peer?.pullOnly;
1013
1344
  }
1014
1345
 
1015
1346
  /**
@@ -1024,6 +1355,7 @@ export class Bus {
1024
1355
  * steps if loss is ever observed.
1025
1356
  */
1026
1357
  pull(id: PeerId): Envelope[] | undefined {
1358
+ if (remoteId(id)) return undefined; // A local inbox read is not signed remote queue admission.
1027
1359
  if (this.storageError) throw new Error("delivery journal unavailable");
1028
1360
  if (this.held(id)) return undefined;
1029
1361
  const queue = this.queues.get(id) ?? [];
@@ -1061,22 +1393,23 @@ export class Bus {
1061
1393
 
1062
1394
  private async drain(id: PeerId): Promise<void> {
1063
1395
  if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
1396
+ if (remoteId(id)) this.expireRemoteQueues();
1064
1397
  this.draining.add(id);
1065
1398
  try {
1066
1399
  const peer = this.peers.get(id)!;
1067
1400
  const queue = this.queues.get(id)!;
1068
1401
  while (!this.storageError && queue.length && this.deliverable(id)) {
1069
1402
  if (this.dropIrrelevant(id, queue) && !queue.length) break;
1070
- const delay = this.wait(queue);
1403
+ const delay = remoteId(id) ? 0 : this.wait(queue);
1071
1404
  if (delay > 0) { this.arm(id, delay); break; }
1072
- const preface = this.prefaces.get(id);
1405
+ const preface = remoteId(id) ? undefined : this.prefaces.get(id);
1073
1406
  // Durable queues retain the batch while asynchronous condensation runs. Another publish
1074
1407
  // may checkpoint during that wait; it must not checkpoint the batch out of existence.
1075
1408
  if (!this.journal) this.prefaces.delete(id);
1076
1409
  const batch = this.take(id, queue, !this.journal);
1077
1410
  this.preparing.set(id, batch);
1078
1411
  const delivery = preface ? [preface, ...batch] : batch;
1079
- const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
1412
+ const mayCondense = this.opts.condense && !remoteId(id) && !delivery.some((e) => e.priority === "important" || remoteId(e.from));
1080
1413
  this.condensing += mayCondense ? 1 : 0;
1081
1414
  let out: Envelope[];
1082
1415
  try {
@@ -1096,7 +1429,7 @@ export class Bus {
1096
1429
  }
1097
1430
  const deliveryId = crypto.randomUUID();
1098
1431
  if (this.journal) {
1099
- if (this.prefaces.get(id) !== preface || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
1432
+ if ((!remoteId(id) && this.prefaces.get(id) !== preface) || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
1100
1433
  const queued = [...queue], seen = [...this.seen];
1101
1434
  for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
1102
1435
  this.prefaces.delete(id);
@@ -1112,8 +1445,14 @@ export class Bus {
1112
1445
  this.activeDeliveries.set(deliveryId, id);
1113
1446
  } else this.remember(out);
1114
1447
  this.lastDelivery.set(id, { out, originals: delivery });
1448
+ if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
1115
1449
  this.preparing.delete(id); // from here on the transport/journal owns settlement
1116
1450
  try {
1451
+ if (remoteId(id)) {
1452
+ const env = delivery[0], expiry = env && this.remoteQueueExpires.get(id)?.get(env.id);
1453
+ if (!env || delivery.length !== 1 || expiry === undefined || expiry <= Date.now() || !peer.setRemoteDeadline) throw new Error("remote queue deadline unavailable");
1454
+ peer.setRemoteDeadline(env.id, expiry);
1455
+ }
1117
1456
  try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
1118
1457
  if (this.journal) await peer.deliver(out, deliveryId);
1119
1458
  else { await peer.deliver(out); this.delivered(id, delivery); }
@@ -1121,7 +1460,7 @@ export class Bus {
1121
1460
  if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
1122
1461
  else this.failed(id, delivery);
1123
1462
  break;
1124
- }
1463
+ } finally { this.remoteTransport.delete(deliveryId); }
1125
1464
  }
1126
1465
  } catch { this.storageError = "delivery journal unavailable"; }
1127
1466
  finally { this.preparing.delete(id); this.draining.delete(id); this.onQueues?.(); }
@@ -1172,7 +1511,7 @@ export class Bus {
1172
1511
  const failedBefore = this.attempts.has(`${id}:${env.id}`);
1173
1512
  if (failedBefore && batch.length) break;
1174
1513
  batch.push(env);
1175
- if (failedBefore || batch.length === DIGEST_MAX) break;
1514
+ if (failedBefore || remoteId(id) || batch.length === DIGEST_MAX) break;
1176
1515
  }
1177
1516
  if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
1178
1517
  return batch;
@@ -1245,6 +1584,10 @@ export class Bus {
1245
1584
 
1246
1585
  private emit(e: BusEvent): void {
1247
1586
  if (this.flushingHub) { this.deferredEvents.push(e); return; }
1587
+ if ("env" in e && (remoteId(e.env.from) || e.env.to?.some(remoteId) || ("peer" in e && remoteId(e.peer)))) {
1588
+ const { refs: _, ...env } = e.env;
1589
+ e = { ...e, env: { ...env, body: "[remote room message withheld]", ...(env.to ? { to: env.to.filter(id => /^[a-z][a-z0-9-]{0,31}$/.test(id) || !!parseRemotePeerId(id)) } : {}) }, ...("reason" in e ? { reason: "remote room delivery" } : {}) };
1590
+ }
1248
1591
  for (const tap of this.taps) tap(e);
1249
1592
  }
1250
1593
  }