@staix/agent-hub 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
package/src/rooms/link.ts CHANGED
@@ -3,7 +3,8 @@ 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
+ import { ROOM_STOP_TIMEOUT_MS, roomRefreshPeriod } from "./liveness.ts";
7
8
  import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.ts";
8
9
 
9
10
  export type RoomLinkState = "offline" | "connecting" | "online";
@@ -13,9 +14,11 @@ export interface RoomLinkOptions {
13
14
  store: RoomStore;
14
15
  name: string;
15
16
  presence?: () => readonly { id: string; state: string }[];
16
- onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[] }) => void;
17
+ onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }) => void;
17
18
  onState?: (state: RoomLinkState) => void;
18
19
  application?: RoomLinkApplication;
20
+ refreshMs?: number;
21
+ now?: () => number;
19
22
  }
20
23
  /** Host-bound application ports; broker/config data never selects callbacks. */
21
24
  export interface RoomLinkContext {
@@ -28,6 +31,7 @@ export interface RoomLinkApplication {
28
31
  stored(context: RoomLinkContext, input: { digest: string; seq: string }): void;
29
32
  /** Fixed transport metadata only; never application completion or authority. */
30
33
  transportAck?(context: RoomLinkContext, input: { digest: string }): void;
34
+ receiptTtlMs?(context: RoomLinkContext): number;
31
35
  capacityChanged?(context: RoomLinkContext): void;
32
36
  gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
33
37
  }
@@ -40,12 +44,34 @@ export class RoomLinkError extends Error {
40
44
  super(`Room link: ${code}`); this.name = "RoomLinkError";
41
45
  }
42
46
  }
47
+ /** Only before durable application enqueue: the Bus may retain its original queue item. */
48
+ export class RoomNotSentError extends Error {
49
+ readonly code = "not_sent";
50
+ constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
51
+ }
52
+ const RECEIPT_TTL_MS = 24 * 3_600_000;
43
53
  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
54
  const decoder = new TextDecoder("utf-8", { fatal: true });
45
55
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
46
56
  const DIGEST = /^[A-Za-z0-9_-]{43}$/;
47
57
  const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
58
+ interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
48
59
  interface Session {
60
+ closingHead?: PendingFrame;
61
+ refresh?: ReturnType<typeof setTimeout>;
62
+ refreshedAt?: number;
63
+ processingFrame?: string;
64
+ acknowledgedFrame?: string;
65
+ pendingPresenceReplies: Set<string>;
66
+ presenceAsked: boolean;
67
+ presenceRoster?: string;
68
+ outbound: PendingFrame[];
69
+ outboundBytes: number;
70
+ frameSent: boolean;
71
+ retryAttempt: number;
72
+ frameRetry?: ReturnType<typeof setTimeout>;
73
+ lastPresence?: string;
74
+ ephemeral?: boolean;
49
75
  ws: WebSocket;
50
76
  generation: number;
51
77
  room: string;
@@ -95,6 +121,11 @@ function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePe
95
121
  /** One bounded, generation-fenced outbound connection. No callback receives body text or task data. */
96
122
  export class RoomLink {
97
123
  private running = false;
124
+ private stopping = false;
125
+ private stopWork?: Promise<void>;
126
+ private stoppedFrame?: { digest: string; resolve: () => void };
127
+ private readonly refreshMs: number;
128
+ private readonly applicationPauses = new Set<symbol>();
98
129
  private generation = 0;
99
130
  private session?: Session;
100
131
  private reconnect?: ReturnType<typeof setTimeout>;
@@ -109,10 +140,12 @@ export class RoomLink {
109
140
  private forcePresenceRequest = false;
110
141
  private republish?: ReturnType<typeof setTimeout>;
111
142
  private sendTail: Promise<void> = Promise.resolve();
112
- constructor(private readonly options: RoomLinkOptions) {}
143
+ constructor(private readonly options: RoomLinkOptions) { this.refreshMs = roomRefreshPeriod(options.refreshMs); }
113
144
 
114
145
  /** Advisory transport state without copying or decoding a room epoch for a status poll. */
115
146
  get connectionState(): RoomLinkState { return this.state; }
147
+ /** Negotiated transport evidence only; unknown until this session is ready. */
148
+ get livenessTracked(): boolean | undefined { return this.session?.phase === "ready" ? this.session.ephemeral : undefined; }
116
149
  status(): { state: RoomLinkState; members: number } {
117
150
  try {
118
151
  const row = this.options.store.get(this.options.name);
@@ -120,39 +153,105 @@ export class RoomLink {
120
153
  } catch { return { state: "offline", members: 0 }; }
121
154
  }
122
155
  async start(): Promise<void> {
123
- if (this.running) return;
156
+ if (this.running || this.stopping) return;
157
+ this.stopWork = undefined;
124
158
  this.running = true;
125
159
  const generation = ++this.generation;
126
160
  try { await assertRoomCryptoReady(); } catch { this.running = false; throw new RoomLinkError("unavailable"); }
127
161
  if (!this.running || generation !== this.generation) return;
128
162
  this.connect();
129
163
  }
130
- stop(): void {
131
- this.running = false;
164
+ stop(): Promise<void> {
165
+ if (this.stopWork) return this.stopWork;
166
+ this.running = false; this.stopping = true;
167
+ const session = this.session;
168
+ if (session && (session.frameSent || session.frameRetry)) session.closingHead = session.outbound[0];
132
169
  if (this.reconnect) clearTimeout(this.reconnect);
133
170
  this.reconnect = undefined;
134
- this.disconnect();
171
+ if (this.republish) clearTimeout(this.republish);
172
+ this.republish = undefined;
173
+ if (session?.refresh) clearTimeout(session.refresh);
174
+ if (session) {
175
+ // A storage control may already be waiting behind a slow application turn.
176
+ // Consume only those controls before sealing the final FIFO observation.
177
+ const queued = session.queue.splice(0); session.queuedBytes = 0;
178
+ for (const bytes of queued) this.receive(session, Uint8Array.from(bytes).buffer);
179
+ }
180
+ this.setState("offline");
181
+ let timer: ReturnType<typeof setTimeout> | undefined;
182
+ const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, ROOM_STOP_TIMEOUT_MS); });
183
+ // Storage controls are consumed directly during shutdown, outside the serial
184
+ // receive lane which may still be awaiting application work being fenced.
185
+ const final = session?.phase === "ready" ? this.finalPresence(session).catch(() => undefined) : Promise.resolve();
186
+ this.stopWork = Promise.race([final, deadline]).finally(() => {
187
+ if (timer) clearTimeout(timer);
188
+ this.disconnect(session); this.stoppedFrame?.resolve(); this.stoppedFrame = undefined; this.stopping = false;
189
+ });
190
+ return this.stopWork;
191
+ }
192
+ private async finalPresence(session: Session): Promise<void> {
193
+ await this.serialSend(async () => {
194
+ const row = this.current(session, undefined, true), identity = this.options.store.identity;
195
+ if (session.phase !== "ready" || session.roster?.digest !== row.current.digest) return;
196
+ const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
197
+ if (!own) fail("untrusted");
198
+ const recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
199
+ if (!recipients.length) return;
200
+ const peers = this.currentPresence(own.exposedPeers).map(peer => ({ ...peer, state: "offline" as const }));
201
+ const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers }), ...(session.ephemeral ? { ephemeral: true as const } : {}), sequence: this.options.store.sequence });
202
+ this.current(session, row.current.digest, true);
203
+ const digest = await this.frameDigest(wire);
204
+ this.current(session, row.current.digest, true);
205
+ // Unattempted RAM work can be withdrawn; durable application/receipt rows
206
+ // remain owned by the Store. An attempted head keeps its exact FIFO place.
207
+ const attempted = session.closingHead;
208
+ session.outbound = attempted ? [attempted] : [];
209
+ session.outboundBytes = attempted?.wire.byteLength ?? 0;
210
+ const stored = new Promise<void>(resolve => { this.stoppedFrame = { digest, resolve }; });
211
+ this.queueFrame(session, wire, digest, false,
212
+ () => { try { this.current(session, row.current.digest, true); return peers.every(peer => this.currentPresence(own.exposedPeers).some(value => value.id === peer.id)); } catch { return false; } }, true);
213
+ return stored;
214
+ });
135
215
  }
136
216
  refresh(): void {
217
+ if (this.stopping) return;
137
218
  if (this.reconnect) clearTimeout(this.reconnect);
138
219
  this.reconnect = undefined;
139
220
  this.disconnect();
140
221
  if (this.running) this.connect();
141
222
  }
223
+ private armPresenceRefresh(session: Session): void {
224
+ if (session.refresh) clearTimeout(session.refresh);
225
+ // Legacy brokers retain state-change/dedupe publication without periodic traffic.
226
+ if (!session.ephemeral || !this.running || this.stopping) return;
227
+ session.refreshedAt = (this.options.now ?? Date.now)();
228
+ session.refresh = setTimeout(() => {
229
+ session.refresh = undefined;
230
+ if (this.session !== session || !this.running || this.stopping || session.phase !== "ready") return;
231
+ const now = (this.options.now ?? Date.now)();
232
+ if (now - session.refreshedAt! >= this.refreshMs) {
233
+ session.pendingPresenceReplies.clear(); session.presenceAsked = false;
234
+ session.requestPresenceReply = true; this.forcePresenceRequest = true;
235
+ void this.publishPresence().catch(() => this.retry(session));
236
+ }
237
+ this.armPresenceRefresh(session);
238
+ }, this.refreshMs);
239
+ session.refresh.unref?.();
240
+ }
142
241
  private setState(state: RoomLinkState): void {
143
242
  if (this.state === state) return;
144
243
  this.state = state;
145
244
  try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
146
245
  }
147
- private current(session: Session, digest?: string): RoomState {
148
- if (!this.running || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
246
+ private current(session: Session, digest?: string, final = false): RoomState {
247
+ if ((!this.running && !(final && this.stopping)) || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
149
248
  let row: RoomState | undefined;
150
249
  try { row = this.options.store.get(this.options.name); } catch { fail("unavailable"); }
151
250
  if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
152
251
  return row;
153
252
  }
154
253
  private isCurrent(session: Session, digest: string): boolean {
155
- try { this.current(session, digest); return true; } catch { return false; }
254
+ try { if (this.stopping) return false; this.current(session, digest); return true; } catch { return false; }
156
255
  }
157
256
  private publicationCurrent(session: Session, digest: string): boolean {
158
257
  return session.phase === "ready" && session.roster?.digest === digest && this.isCurrent(session, digest);
@@ -165,6 +264,7 @@ export class RoomLink {
165
264
  if (!this.publicationCurrent(session, row.current.digest) || !this.options.store.ordinaryInboxAvailable(this.options.name, bytes)) return;
166
265
  // Intent survives coalescing, ordinary responses and publication failure.
167
266
  // Only an actually sent request with current capacity may clear the numeric flag.
267
+ if (!this.forcePresenceRequest) { session.pendingPresenceReplies.clear(); session.presenceAsked = false; }
168
268
  session.requestPresenceReply = true; this.forcePresenceRequest = true;
169
269
  try { await this.publishPresence(); }
170
270
  catch (error) { this.retry(session); throw error; }
@@ -194,7 +294,7 @@ export class RoomLink {
194
294
  }
195
295
  let peers: RoomPresencePeer[] | undefined;
196
296
  try { peers = presence(entry.plaintext, member.exposedPeers); } catch { /* a rotation may have removed an exposed id since the durable receive */ }
197
- if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers }); } catch { /* observation only */ } }
297
+ if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers, restored: true }); } catch { /* observation only */ } }
198
298
  }
199
299
  // Presence is ephemeral: stale exposure/member metadata is discarded, never
200
300
  // replayed after a rotation or left at the head of recovery forever.
@@ -204,6 +304,7 @@ export class RoomLink {
204
304
  }
205
305
  }
206
306
  private async deliverApplication(session: Session, digest: string, input: Parameters<RoomLinkApplication["receive"]>[1]): Promise<void> {
307
+ if (!this.running || this.stopping) fail("state_changed");
207
308
  try { await this.options.application?.receive(this.context(session, digest), input); }
208
309
  catch (error) { if (!(error instanceof RoomApplicationDeferredError)) throw error; }
209
310
  this.current(session, digest);
@@ -233,12 +334,16 @@ export class RoomLink {
233
334
  this.republish = undefined;
234
335
  if (session) {
235
336
  clearTimeout(session.handshake);
337
+ if (session.refresh) clearTimeout(session.refresh);
338
+ if (session.frameRetry) clearTimeout(session.frameRetry);
339
+ session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
236
340
  session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
237
341
  try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
238
342
  }
239
343
  this.setState("offline");
240
344
  }
241
345
  private retry(session?: Session): void {
346
+ if (this.stopping) return;
242
347
  if (session && this.session !== session) return;
243
348
  this.disconnect(session);
244
349
  if (!this.running || this.reconnect) return;
@@ -259,10 +364,11 @@ export class RoomLink {
259
364
  } catch { this.setState("offline"); return; }
260
365
  url.pathname = `/v1/rooms/${row.current.room}`;
261
366
  let ws: WebSocket;
262
- try { ws = new WebSocket(url.href); } catch { this.retry(); return; }
367
+ // ponytail: negotiate only ephemeral here; replace this opt-in with #1204 T4 codec capabilities.
368
+ try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
263
369
  ws.binaryType = "arraybuffer";
264
370
  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,
371
+ 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
372
  handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
267
373
  session.handshake.unref?.();
268
374
  this.session = session;
@@ -272,11 +378,84 @@ export class RoomLink {
272
378
  ws.addEventListener("error", () => this.retry(session));
273
379
  }
274
380
  private send(session: Session, bytes: Uint8Array): void {
275
- this.current(session);
381
+ this.current(session, undefined, this.stopping);
276
382
  if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
277
383
  session.ws.send(bytes);
278
384
  }
385
+ private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
386
+ this.current(session, undefined, this.stopping);
387
+ // Only an unsent observation may be replaced. Never reorder an attempted frame.
388
+ if (presence) {
389
+ const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
390
+ if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
391
+ }
392
+ if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
393
+ session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
394
+ this.pumpFrames(session);
395
+ }
396
+ private pumpFrames(session: Session): void {
397
+ if (session.frameSent || session.frameRetry || !session.outbound.length) return;
398
+ this.current(session, undefined, this.stopping);
399
+ const frame = session.outbound[0]!;
400
+ // An expired backoff timer is not proof that a pre-stop attempt was stored.
401
+ if (this.stopping && frame === session.closingHead) return;
402
+ if (frame.retained && !frame.retained()) {
403
+ // Disposed/expired intent cannot survive in the transport's RAM queue.
404
+ session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
405
+ this.pumpFrames(session); return;
406
+ }
407
+ if (!frame.current()) {
408
+ if (!frame.presence) fail("state_changed");
409
+ session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.lastPresence = undefined;
410
+ this.pumpFrames(session);
411
+ void this.publishPresence().catch(() => this.retry(session));
412
+ return;
413
+ }
414
+ // A foreground receipt follows the chat ACK on the socket. Otherwise broker
415
+ // verification/storage of the receipt can pin the chat's one-packet slot.
416
+ if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
417
+ this.send(session, frame.wire); session.frameSent = true;
418
+ const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
419
+ }
420
+ private storedFrame(session: Session, digest: string): void {
421
+ if (session.outbound[0]?.digest !== digest) return;
422
+ const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
423
+ if (session.closingHead === frame) session.closingHead = undefined;
424
+ session.frameSent = false; session.retryAttempt = 0;
425
+ if (session.frameRetry) clearTimeout(session.frameRetry);
426
+ session.frameRetry = undefined;
427
+ if (this.stoppedFrame?.digest === digest) this.stoppedFrame.resolve();
428
+ this.pumpFrames(session);
429
+ }
430
+ private backoffFrame(session: Session, digest: string): void {
431
+ if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
432
+ session.frameSent = false;
433
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
434
+ session.frameRetry = setTimeout(() => {
435
+ session.frameRetry = undefined;
436
+ if (this.session !== session || !this.running || session.phase !== "ready") return;
437
+ try { this.pumpFrames(session); } catch { this.retry(session); }
438
+ }, delay);
439
+ session.frameRetry.unref?.();
440
+ this.pumpFrames(session);
441
+ }
279
442
  private receive(session: Session, data: unknown): void {
443
+ if (this.stopping && this.session === session) {
444
+ if (!(data instanceof ArrayBuffer) || data.byteLength > LIMITS.packetBytes) return;
445
+ try {
446
+ const row = exact(packet(new Uint8Array(data)), ["v", "type", "digest", "seq", "duplicate"]);
447
+ if (row.type === "stored" && typeof row.digest === "string" && DIGEST.test(row.digest)
448
+ && typeof row.seq === "string" && /^[1-9][0-9]{0,19}$/.test(row.seq) && typeof row.duplicate === "boolean") {
449
+ this.current(session, undefined, true);
450
+ if (this.options.store.receiptSendable(this.options.name, row.digest)) {
451
+ this.options.store.markStored(this.options.name, row.digest, row.seq);
452
+ this.options.store.retireStoredReceipt(this.options.name, row.digest);
453
+ }
454
+ this.storedFrame(session, row.digest);
455
+ }
456
+ } catch { /* Invalid controls cannot complete graceful shutdown. */ }
457
+ return;
458
+ }
280
459
  if (this.session !== session || !this.running) return;
281
460
  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; }
282
461
  const bytes = new Uint8Array(data).slice();
@@ -327,20 +506,29 @@ export class RoomLink {
327
506
  return;
328
507
  }
329
508
  if (message.type === "ready") {
330
- const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest"]), roster = session.roster;
509
+ const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
510
+ if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
331
511
  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
512
  if (roster.digest !== local.current.digest) {
333
513
  if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
334
514
  session.ownerUpgrading = true;
335
515
  this.send(session, local.wire); return;
336
516
  }
517
+ if (session.presenceRoster !== roster.digest) {
518
+ session.presenceRoster = roster.digest; session.pendingPresenceReplies.clear(); session.presenceAsked = false;
519
+ session.requestPresenceReply = true; session.lastPresence = undefined;
520
+ }
521
+ session.ephemeral = ready.ephemeral === true;
337
522
  session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
338
523
  if (session.deferredFrame) {
339
524
  session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
340
525
  }
526
+ if (!this.running || this.stopping) return;
527
+ this.armPresenceRefresh(session);
341
528
  this.setState("online");
342
529
  const context = this.context(session, local.current.digest);
343
530
  this.options.application?.members(context);
531
+ this.restoreReceipts(session);
344
532
  await this.retryPendingApplications();
345
533
  this.current(session, local.current.digest);
346
534
  await this.options.application?.online(context);
@@ -365,7 +553,14 @@ export class RoomLink {
365
553
  if (message.type === "stored") {
366
554
  const row = exact(message, ["v", "type", "digest", "seq", "duplicate"]);
367
555
  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();
556
+ const receipt = this.options.store.receiptSendable(this.options.name, row.digest);
557
+ if (receipt) {
558
+ this.options.store.markStored(this.options.name, row.digest, row.seq);
559
+ this.options.store.retireStoredReceipt(this.options.name, row.digest);
560
+ }
561
+ this.storedFrame(session, row.digest);
368
562
  this.options.application?.stored(this.context(session, local.current.digest), { digest: row.digest, seq: row.seq });
563
+ this.restoreReceipts(session);
369
564
  return; // Broker storage is not a signed recipient application-completion receipt.
370
565
  }
371
566
  if (message.type === "queue_ack") {
@@ -381,10 +576,17 @@ export class RoomLink {
381
576
  return;
382
577
  }
383
578
  if (message.type === "error") {
384
- const error = exact(message, ["v", "type", "code"]);
579
+ const error = exact(message, Object.hasOwn(message, "digest") ? ["v", "type", "code", "digest"] : ["v", "type", "code"]);
580
+ if (error.digest !== undefined && (error.code !== "queue_full" || typeof error.digest !== "string" || !DIGEST.test(error.digest))) fail();
581
+ if (error.code === "queue_full") {
582
+ // Older brokers omit correlation; one frame in flight makes its head unambiguous.
583
+ const digest = typeof error.digest === "string" ? error.digest : session.outbound[0]?.digest;
584
+ if (digest) this.backoffFrame(session, digest);
585
+ return;
586
+ }
385
587
  // X2 declares these refusal controls nonterminal. Their unsigned control
386
588
  // 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;
589
+ if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
388
590
  fail("unavailable");
389
591
  }
390
592
  fail();
@@ -449,6 +651,7 @@ export class RoomLink {
449
651
  else throw error;
450
652
  }
451
653
  const acceptedDigest = await this.frameDigest(bytes);
654
+ session.processingFrame = acceptedDigest;
452
655
  const latest = this.frameCurrent(session, frame);
453
656
  if (latest.current.digest !== digest) refused = "trust_changed";
454
657
  if (result?.status === "stale") {
@@ -471,6 +674,8 @@ export class RoomLink {
471
674
  } else {
472
675
  const peers = presence(result.plaintext, sender.exposedPeers);
473
676
  this.current(session, digest);
677
+ session.pendingPresenceReplies.delete(frame.sender);
678
+ if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
474
679
  try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
475
680
  this.current(session, digest);
476
681
  this.options.store.settleInbox(this.options.name, acceptedDigest);
@@ -486,9 +691,33 @@ export class RoomLink {
486
691
  this.frameCurrent(session, frame);
487
692
  // This ACK only retires ciphertext from the broker queue. A durable refusal
488
693
  // or stale drop is never a signed recipient application-completion receipt.
489
- this.send(session, encodeBrokerControl({ v: 1, type: "received", digest: acceptedDigest }));
694
+ this.retireFrame(session, frame, acceptedDigest);
695
+ session.processingFrame = undefined; session.acknowledgedFrame = undefined;
490
696
  await this.refreshPresenceIfCapacityFree().catch(() => undefined);
491
697
  }
698
+ private retireFrame(session: Session, frame: FrameWire, digest: string): void {
699
+ this.frameCurrent(session, frame);
700
+ // Final admission commits its receipt; deferred admission retains the durable inbox.
701
+ // Waiting for broker storage or deferred admission would pin capacity one indefinitely.
702
+ if (this.options.store.applicationReceipt(this.options.name, digest)
703
+ && !this.options.store.receiptStored(this.options.name, digest)) fail("unavailable");
704
+ this.send(session, encodeBrokerControl({ v: 1, type: "received", digest }));
705
+ session.acknowledgedFrame = digest;
706
+ this.pumpFrames(session);
707
+ }
708
+ private restoreReceipts(session: Session): void {
709
+ if (!this.running || this.stopping) return;
710
+ this.current(session);
711
+ this.options.store.expireReceipts(this.options.name);
712
+ for (const entry of this.options.store.outbox(this.options.name)) {
713
+ if (entry.kind !== "receipt" || session.outbound.some(frame => frame.digest === entry.digest)) continue;
714
+ if (session.outbound.length >= LIMITS.packets || session.outboundBytes + entry.wire.byteLength > LIMITS.queuedBytes) break;
715
+ const roster = this.current(session).current.digest;
716
+ this.queueFrame(session, entry.wire, entry.digest, false, () => this.publicationCurrent(session, roster), false,
717
+ () => this.options.store.receiptSendable(this.options.name, entry.digest), undefined,
718
+ entry.receiptFor === session.processingFrame ? entry.receiptFor : undefined);
719
+ }
720
+ }
492
721
  private async frameDigest(bytes: Uint8Array): Promise<string> {
493
722
  return encodeBase64url(await sha256(bytes));
494
723
  }
@@ -508,34 +737,100 @@ export class RoomLink {
508
737
  this.sendTail = next.then(() => undefined, () => undefined);
509
738
  return next;
510
739
  }
740
+ /** Host-only admission pause; trust/current predicates remain independent. */
741
+ async quiesceApplications(): Promise<() => void> {
742
+ const token = Symbol(); this.applicationPauses.add(token);
743
+ await this.sendTail;
744
+ let released = false;
745
+ return () => { if (!released) { released = true; this.applicationPauses.delete(token); } };
746
+ }
511
747
  async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
512
748
  const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
513
749
  const session = this.session;
514
- if (!session || session.phase !== "ready") fail("unavailable");
750
+ if (this.stopping || !this.running || !session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
751
+ if (this.applicationPauses.size) { if (metadata) throw new RoomNotSentError(); fail("state_changed"); }
515
752
  const digest = this.current(session).current.digest;
516
753
  const allowed = () => this.publicationCurrent(session, digest) && current();
517
- return this.serialSend(async () => {
518
- if (!allowed()) fail("state_changed");
754
+ const admitted = () => this.applicationPauses.size === 0 && allowed();
755
+ let queueStarted = false;
756
+ try {
757
+ let presenceWork: Promise<void> | undefined;
758
+ // A chat is the transport barrier for earlier observations. Receipts keep
759
+ // their durable-commit/ACK path and do not wait behind a new observation.
760
+ if (message.type === "chat" && this.republish) {
761
+ clearTimeout(this.republish); this.republish = undefined;
762
+ presenceWork = this.publishPresence();
763
+ // It may fail before reaching the serial lane. Observe rejection now;
764
+ // the reserved chat slot still awaits the original error below.
765
+ void presenceWork.catch(() => undefined);
766
+ }
767
+ // Reserve this chat's FIFO slot before yielding to the earlier observation.
768
+ return await this.serialSend(async () => {
769
+ if (presenceWork) await presenceWork;
770
+ if (!admitted()) fail("state_changed");
771
+ if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
772
+ || session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
773
+ if (message.type === "receipt") {
774
+ const pending = this.options.store.receiptStored(this.options.name, message.frame);
775
+ if (pending?.state === "dispatching") {
776
+ if (!decodeFrame(pending.wire).recipients.includes(recipient)) fail("untrusted");
777
+ this.restoreReceipts(session);
778
+ return pending.digest;
779
+ }
780
+ }
519
781
  const row = this.current(session, digest);
520
782
  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 });
522
- 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);
783
+ let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
784
+ if (!admitted()) fail("state_changed");
785
+ let frameDigest: string;
786
+ if (metadata) {
787
+ queueStarted = true;
788
+ let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
789
+ try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, admitted); }
790
+ catch (error) {
791
+ // An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
792
+ if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
793
+ && !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
794
+ throw error;
795
+ }
796
+ frameDigest = record.frameDigest ?? record.digest;
797
+ } else if (message.type === "receipt") {
798
+ const ttlMs = this.options.application?.receiptTtlMs?.(this.context(session, digest)) ?? RECEIPT_TTL_MS;
799
+ const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, admitted);
800
+ if (!allowed()) fail("state_changed");
801
+ if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
802
+ frameDigest = receipt.digest; wire = receipt.wire;
803
+ } else frameDigest = await this.frameDigest(wire);
525
804
  if (!allowed()) fail("state_changed");
526
- this.send(session, wire);
805
+ try {
806
+ this.queueFrame(session, wire, frameDigest, false, allowed, false,
807
+ metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
808
+ : message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
809
+ undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
810
+ } catch (error) {
811
+ // The durable outbox owns retry after local backpressure; this is no uncertainty.
812
+ if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
813
+ if (session.outbound[0]?.digest === frameDigest) this.backoffFrame(session, frameDigest);
814
+ }
527
815
  return frameDigest;
528
- });
816
+ }); } catch (error) {
817
+ if (metadata && !queueStarted) throw new RoomNotSentError();
818
+ throw error;
819
+ }
529
820
  }
530
821
  async publishPresence(): Promise<void> {
531
822
  const session = this.session;
532
- if (!session || session.phase !== "ready") return;
823
+ if (!this.running || this.stopping || !session || session.phase !== "ready") return;
533
824
  if (this.publishing) { this.publishDirty = true; return; }
825
+ // Keep the already scheduled coalesced snapshot. Cancelling it on every
826
+ // state flip turns an awaited burst back into one durable seal per call.
827
+ if (this.republish && !this.replyPresence && !this.forcePresenceRequest) { this.publishDirty = true; return; }
534
828
  if (this.republish) clearTimeout(this.republish);
535
829
  this.republish = undefined; this.publishDirty = false;
536
- const response = this.replyPresence && !this.forcePresenceRequest;
830
+ const forceRequest = this.forcePresenceRequest;
831
+ const response = this.replyPresence;
537
832
  if (response) this.replyPresence = false;
538
- this.forcePresenceRequest = false;
833
+ this.forcePresenceRequest = response && forceRequest;
539
834
  let sent = false;
540
835
  this.publishing = true;
541
836
  try {
@@ -549,17 +844,24 @@ export class RoomLink {
549
844
  await this.serialSend(async () => {
550
845
  if (!this.publicationCurrent(session, row.current.digest)) return;
551
846
  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 });
847
+ const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
848
+ if (!response && !forceRequest && session.lastPresence === presenceKey) return;
849
+ 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
850
  if (!this.publicationCurrent(session, row.current.digest)) return;
554
851
  // Machine-local exposure can change during reserve/encryption/signing even
555
852
  // when the signed roster is unchanged. Never emit an id it now withholds.
853
+ const frameDigest = await this.frameDigest(wire);
854
+ if (!this.publicationCurrent(session, row.current.digest)) return;
556
855
  const allowed = new Set(this.currentPresence(own.exposedPeers).map(peer => peer.id));
557
856
  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
- }
857
+ this.queueFrame(session, wire, frameDigest, requestReply,
858
+ () => this.publicationCurrent(session, row.current.digest) && peers.every(peer => this.currentPresence(own.exposedPeers).some(value => value.id === peer.id)), true, undefined,
859
+ requestReply ? () => {
860
+ if (!session.presenceAsked) { session.pendingPresenceReplies = new Set(recipients); session.presenceAsked = true; }
861
+ session.requestPresenceReply = session.pendingPresenceReplies.size > 0;
862
+ if (this.retiredPresenceBytes && this.options.store.ordinaryInboxAvailable(this.options.name, this.retiredPresenceBytes)) this.retiredPresenceBytes = 0;
863
+ } : undefined);
864
+ session.lastPresence = presenceKey; sent = true;
563
865
  });
564
866
  } catch (error) {
565
867
  this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
@@ -0,0 +1,11 @@
1
+ /** Host timing only; signed presence remains the existing v1 ids/state payload. */
2
+ export const ROOM_REFRESH_MS = 30_000;
3
+ export const ROOM_PRESENCE_MISSES = 3;
4
+ export const ROOM_STOP_TIMEOUT_MS = 2_000;
5
+
6
+ export function roomRefreshPeriod(value = ROOM_REFRESH_MS): number {
7
+ if (!Number.isSafeInteger(value) || value < 1 || value > Math.floor(2_147_483_647 / ROOM_PRESENCE_MISSES)) {
8
+ throw new Error("room refresh period is invalid; use a positive whole duration within the timer bound");
9
+ }
10
+ return value;
11
+ }