@staix/agent-hub 0.31.0 → 0.33.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 (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/bus.md +3 -1
  4. package/docs/agent-notes/daemon.md +1 -0
  5. package/docs/agent-notes/tests.md +2 -2
  6. package/docs/design/bus.md +109 -4
  7. package/docs/dogfood.md +2 -0
  8. package/docs/memory.md +9 -3
  9. package/docs/operations.md +98 -17
  10. package/docs/quickstart.md +18 -2
  11. package/docs/security.md +52 -9
  12. package/docs/specs/2026-09-19-agent-hub-design.md +75 -5
  13. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  14. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +55 -1
  15. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  16. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  17. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  18. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  19. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  20. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  21. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  22. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  23. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  24. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  25. package/docs/verified.json +11 -11
  26. package/package.json +2 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +528 -206
  29. package/src/adapters/compat.ts +11 -11
  30. package/src/broker/protocol.ts +5 -0
  31. package/src/broker/server.ts +54 -4
  32. package/src/broker/store.ts +6 -3
  33. package/src/cli/console-state.ts +1 -1
  34. package/src/cli/output.ts +5 -1
  35. package/src/cli/room.ts +34 -12
  36. package/src/cli/status-lines.ts +8 -1
  37. package/src/cli/terminal-recovery.ts +37 -15
  38. package/src/hub/bus.ts +25 -0
  39. package/src/hub/daemon.ts +20 -9
  40. package/src/rooms/application.ts +15 -0
  41. package/src/rooms/commands.ts +135 -30
  42. package/src/rooms/health.ts +32 -0
  43. package/src/rooms/link.ts +394 -56
  44. package/src/rooms/liveness.ts +11 -0
  45. package/src/rooms/remote-peer.ts +18 -3
  46. package/src/rooms/service.ts +167 -28
  47. package/src/rooms/status.ts +65 -2
  48. package/src/rooms/store.ts +30 -1
  49. package/src/rooms/wire.ts +10 -8
  50. package/src/ui/index.html +5 -3
package/src/rooms/link.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /** Outbound room transport. The local bus and application delivery boundary belong to X4. */
2
- import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "../broker/protocol.ts";
2
+ import { BROKER_CODEC_FEATURES_OPT_IN, createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "../broker/protocol.ts";
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
6
  import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, RoomStoreError, type RoomOutgoingInput } from "./store.ts";
7
- import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.ts";
7
+ import { ROOM_STOP_TIMEOUT_MS, roomRefreshPeriod } from "./liveness.ts";
8
+ import { roomHealthCause, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
9
+ import { ROOM_CODEC_FEATURES, roomCodecFeatures, roomRequiredCodecFeature, decodeRoomMessage, encodeRoomMessage, type RoomCodecFeature, type RoomMessage } from "./application.ts";
8
10
 
9
11
  export type RoomLinkState = "offline" | "connecting" | "online";
10
12
  export type RoomPresenceState = "idle" | "busy" | "paused" | "offline";
@@ -13,9 +15,11 @@ export interface RoomLinkOptions {
13
15
  store: RoomStore;
14
16
  name: string;
15
17
  presence?: () => readonly { id: string; state: string }[];
16
- onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[] }) => void;
18
+ onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }) => void;
17
19
  onState?: (state: RoomLinkState) => void;
18
20
  application?: RoomLinkApplication;
21
+ refreshMs?: number;
22
+ now?: () => number;
19
23
  }
20
24
  /** Host-bound application ports; broker/config data never selects callbacks. */
21
25
  export interface RoomLinkContext {
@@ -30,7 +34,7 @@ export interface RoomLinkApplication {
30
34
  transportAck?(context: RoomLinkContext, input: { digest: string }): void;
31
35
  receiptTtlMs?(context: RoomLinkContext): number;
32
36
  capacityChanged?(context: RoomLinkContext): void;
33
- gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
37
+ codecsChanged?(context: RoomLinkContext, input: { member: string; features?: readonly RoomCodecFeature[] }): void;
34
38
  }
35
39
  /** Host port signal: no application claim/effect occurred; the durable plaintext remains pending. */
36
40
  export class RoomApplicationDeferredError extends Error {
@@ -42,18 +46,36 @@ export class RoomLinkError extends Error {
42
46
  }
43
47
  }
44
48
  /** Only before durable application enqueue: the Bus may retain its original queue item. */
49
+ export class RoomCodecUnsupportedError extends Error {
50
+ readonly code = "codec_unsupported";
51
+ constructor(readonly feature: RoomCodecFeature) {
52
+ super(`Room send refused: recipient codec support for ${feature} is unavailable; upgrade both hubs and the broker, reconnect the room, then retry.`);
53
+ this.name = "RoomCodecUnsupportedError";
54
+ }
55
+ }
45
56
  export class RoomNotSentError extends Error {
46
57
  readonly code = "not_sent";
47
58
  constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
48
59
  }
60
+ /** No compatibility observation exists yet; retain work under its original queue deadline. */
61
+ export class RoomCodecPendingError extends RoomNotSentError {
62
+ constructor(readonly changed: () => boolean) {
63
+ super(); this.name = "RoomCodecPendingError";
64
+ this.message = "Room codec observation is pending; wait for a signed codec advertisement, then retry.";
65
+ }
66
+ }
67
+ export type RoomCodecState = "pending" | "supported" | "unsupported";
49
68
  const RECEIPT_TTL_MS = 24 * 3_600_000;
50
69
  const LIMITS = Object.freeze({ packets: 8, queuedBytes: 2_097_152, packetBytes: 1_048_576, backpressure: 1_048_576, handshakeMs: 30_000, reconnectMaxMs: 300_000 });
51
70
  const decoder = new TextDecoder("utf-8", { fatal: true });
52
71
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
53
72
  const DIGEST = /^[A-Za-z0-9_-]{43}$/;
54
73
  const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
55
- interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
74
+ interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string; codecFeatures?: boolean; supported?: () => boolean; codecPending?: () => boolean; codecTarget?: { member: string; feature: RoomCodecFeature } }
56
75
  interface Session {
76
+ closingHead?: PendingFrame;
77
+ refresh?: ReturnType<typeof setTimeout>;
78
+ refreshedAt?: number;
57
79
  processingFrame?: string;
58
80
  acknowledgedFrame?: string;
59
81
  pendingPresenceReplies: Set<string>;
@@ -66,6 +88,13 @@ interface Session {
66
88
  frameRetry?: ReturnType<typeof setTimeout>;
67
89
  lastPresence?: string;
68
90
  ephemeral?: boolean;
91
+ codecFeatures?: boolean;
92
+ codecSession?: string;
93
+ codecPublishing?: Promise<void>;
94
+ codecDirty?: boolean;
95
+ pendingPresence?: Map<string, { session: string | null; epoch: string; peers: RoomPresencePeer[]; observedAt: number | null }>;
96
+ codecPeers?: Map<string, { session: string | null; supported: boolean }>;
97
+ features?: Map<string, { session: string; features: readonly string[] }>;
69
98
  ws: WebSocket;
70
99
  generation: number;
71
100
  room: string;
@@ -100,7 +129,8 @@ function packet(bytes: Uint8Array): Record<string, unknown> {
100
129
  return row;
101
130
  }
102
131
  function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePeer[] {
103
- const decoded = packet(bytes), row = exact(decoded, Object.hasOwn(decoded, "requestReply") ? ["v", "type", "peers", "requestReply"] : ["v", "type", "peers"]);
132
+ const decoded = packet(bytes), row = exact(decoded, ["v", "type", "peers", ...(Object.hasOwn(decoded, "requestReply") ? ["requestReply"] : []), ...(Object.hasOwn(decoded, "features") ? ["features", "codecSession"] : [])]);
133
+ if (Object.hasOwn(row, "features")) { roomCodecFeatures(row.features); if (typeof row.codecSession !== "string" || !DIGEST.test(row.codecSession)) fail(); }
104
134
  if (row.requestReply !== undefined && typeof row.requestReply !== "boolean") fail();
105
135
  if (row.type !== "presence" || !Array.isArray(row.peers) || row.peers.length > 64) fail();
106
136
  const peers = row.peers.map(value => {
@@ -115,11 +145,19 @@ function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePe
115
145
  /** One bounded, generation-fenced outbound connection. No callback receives body text or task data. */
116
146
  export class RoomLink {
117
147
  private running = false;
148
+ private stopping = false;
149
+ private stopWork?: Promise<void>;
150
+ private stoppedFrame?: { digest: string; resolve: () => void };
151
+ private readonly refreshMs: number;
152
+ private readonly applicationPauses = new Set<symbol>();
118
153
  private generation = 0;
119
154
  private session?: Session;
120
155
  private reconnect?: ReturnType<typeof setTimeout>;
121
156
  private attempts = 0;
122
157
  private state: RoomLinkState = "offline";
158
+ private cause?: RoomHealthCause;
159
+ private blockedCodec?: { member: string; feature: RoomCodecFeature };
160
+ private clockHighWater?: number;
123
161
  private publishing = false;
124
162
  private publishDirty = false;
125
163
  private replyPresence = false;
@@ -129,50 +167,158 @@ export class RoomLink {
129
167
  private forcePresenceRequest = false;
130
168
  private republish?: ReturnType<typeof setTimeout>;
131
169
  private sendTail: Promise<void> = Promise.resolve();
132
- constructor(private readonly options: RoomLinkOptions) {}
170
+ constructor(private readonly options: RoomLinkOptions) { this.refreshMs = roomRefreshPeriod(options.refreshMs); }
133
171
 
134
172
  /** Advisory transport state without copying or decoding a room epoch for a status poll. */
135
173
  get connectionState(): RoomLinkState { return this.state; }
136
- status(): { state: RoomLinkState; members: number } {
174
+ get connectionCause(): RoomHealthCause | undefined { return this.cause; }
175
+ /** Negotiated transport evidence only; unknown until this session is ready. */
176
+ codecState(recipient: string, feature: RoomCodecFeature): RoomCodecState {
177
+ const session = this.session;
178
+ if (!session || session.phase !== "ready") return "pending";
179
+ if (session.codecFeatures !== true) return "unsupported";
180
+ const peer = session.codecPeers?.get(recipient);
181
+ if (!peer || peer.session === null) return "pending";
182
+ if (!peer.supported) return "unsupported";
183
+ const advertised = session.features?.get(recipient);
184
+ if (!advertised || advertised.session !== peer.session) return "pending";
185
+ return advertised.features.includes(feature) ? "supported" : "unsupported";
186
+ }
187
+ codecSupported(recipient: string, feature: RoomCodecFeature): boolean { return this.codecState(recipient, feature) === "supported"; }
188
+ private codecRefusal(recipient: string, feature: RoomCodecFeature): RoomNotSentError | RoomCodecUnsupportedError {
189
+ if (this.codecState(recipient, feature) === "unsupported") return new RoomCodecUnsupportedError(feature);
190
+ const session = this.session, peer = session?.codecPeers?.get(recipient), advertisement = session?.features?.get(recipient);
191
+ return new RoomCodecPendingError(() => this.session !== session || session?.codecPeers?.get(recipient) !== peer
192
+ || session?.features?.get(recipient) !== advertisement || this.codecState(recipient, feature) !== "pending");
193
+ }
194
+ get livenessTracked(): boolean | undefined { return this.session?.phase === "ready" ? this.session.ephemeral : undefined; }
195
+ status(): { state: RoomLinkState; members: number; cause?: RoomHealthCause; nextStep?: string } {
137
196
  try {
138
197
  const row = this.options.store.get(this.options.name);
139
- return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0 };
140
- } catch { return { state: "offline", members: 0 }; }
198
+ return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0,
199
+ ...(this.cause ? { cause: this.cause, nextStep: roomHealthNextStep(this.cause) } : {}) };
200
+ } catch (error) { const cause = roomHealthCause(error); return { state: "offline", members: 0, cause, nextStep: roomHealthNextStep(cause) }; }
141
201
  }
142
202
  async start(): Promise<void> {
143
- if (this.running) return;
203
+ if (this.running || this.stopping) return;
204
+ this.stopWork = undefined;
144
205
  this.running = true;
145
206
  const generation = ++this.generation;
146
207
  try { await assertRoomCryptoReady(); } catch { this.running = false; throw new RoomLinkError("unavailable"); }
147
208
  if (!this.running || generation !== this.generation) return;
148
209
  this.connect();
149
210
  }
150
- stop(): void {
151
- this.running = false;
211
+ stop(): Promise<void> {
212
+ if (this.stopWork) return this.stopWork;
213
+ this.running = false; this.stopping = true;
214
+ const session = this.session;
215
+ if (session && (session.frameSent || session.frameRetry)) session.closingHead = session.outbound[0];
152
216
  if (this.reconnect) clearTimeout(this.reconnect);
153
217
  this.reconnect = undefined;
154
- this.disconnect();
218
+ if (this.republish) clearTimeout(this.republish);
219
+ this.republish = undefined;
220
+ if (session?.refresh) clearTimeout(session.refresh);
221
+ if (session) {
222
+ // A storage control may already be waiting behind a slow application turn.
223
+ // Consume only those controls before sealing the final FIFO observation.
224
+ const queued = session.queue.splice(0); session.queuedBytes = 0;
225
+ for (const bytes of queued) this.receive(session, Uint8Array.from(bytes).buffer);
226
+ }
227
+ this.setState("offline");
228
+ let timer: ReturnType<typeof setTimeout> | undefined;
229
+ const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, ROOM_STOP_TIMEOUT_MS); });
230
+ // Storage controls are consumed directly during shutdown, outside the serial
231
+ // receive lane which may still be awaiting application work being fenced.
232
+ const final = session?.phase === "ready" ? this.finalPresence(session).catch(() => undefined) : Promise.resolve();
233
+ this.stopWork = Promise.race([final, deadline]).finally(() => {
234
+ if (timer) clearTimeout(timer);
235
+ this.disconnect(session); this.stoppedFrame?.resolve(); this.stoppedFrame = undefined; this.stopping = false;
236
+ });
237
+ return this.stopWork;
238
+ }
239
+ private async finalPresence(session: Session): Promise<void> {
240
+ await this.serialSend(async () => {
241
+ const row = this.current(session, undefined, true), identity = this.options.store.identity;
242
+ if (session.phase !== "ready" || session.roster?.digest !== row.current.digest) return;
243
+ const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
244
+ if (!own) fail("untrusted");
245
+ const recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
246
+ if (!recipients.length) return;
247
+ const peers = this.currentPresence(own.exposedPeers).map(peer => ({ ...peer, state: "offline" as const }));
248
+ 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 });
249
+ this.current(session, row.current.digest, true);
250
+ const digest = await this.frameDigest(wire);
251
+ this.current(session, row.current.digest, true);
252
+ // Unattempted RAM work can be withdrawn; durable application/receipt rows
253
+ // remain owned by the Store. An attempted head keeps its exact FIFO place.
254
+ const attempted = session.closingHead;
255
+ session.outbound = attempted ? [attempted] : [];
256
+ session.outboundBytes = attempted?.wire.byteLength ?? 0;
257
+ const stored = new Promise<void>(resolve => { this.stoppedFrame = { digest, resolve }; });
258
+ this.queueFrame(session, wire, digest, false,
259
+ () => { 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);
260
+ return stored;
261
+ });
155
262
  }
156
263
  refresh(): void {
264
+ if (this.stopping) return;
157
265
  if (this.reconnect) clearTimeout(this.reconnect);
158
266
  this.reconnect = undefined;
159
267
  this.disconnect();
160
268
  if (this.running) this.connect();
161
269
  }
162
- private setState(state: RoomLinkState): void {
163
- if (this.state === state) return;
164
- this.state = state;
270
+ private armPresenceRefresh(session: Session): void {
271
+ if (session.refresh) clearTimeout(session.refresh);
272
+ // Legacy brokers retain state-change/dedupe publication without periodic traffic.
273
+ if (!session.ephemeral || !this.running || this.stopping) return;
274
+ session.refreshedAt = this.observationTime();
275
+ session.refresh = setTimeout(() => {
276
+ session.refresh = undefined;
277
+ if (this.session !== session || !this.running || this.stopping || session.phase !== "ready") return;
278
+ const now = this.observationTime();
279
+ if (now !== undefined && session.refreshedAt !== undefined && now - session.refreshedAt >= this.refreshMs) {
280
+ session.pendingPresenceReplies.clear(); session.presenceAsked = false;
281
+ session.requestPresenceReply = true; this.forcePresenceRequest = true;
282
+ void this.publishPresence().catch(() => this.retry(session));
283
+ }
284
+ this.armPresenceRefresh(session);
285
+ }, this.refreshMs);
286
+ session.refresh.unref?.();
287
+ }
288
+ private observationTime(): number | undefined {
289
+ let now: number;
290
+ try { now = (this.options.now ?? Date.now)(); } catch { this.setState(this.state, "unavailable"); return undefined; }
291
+ if (!Number.isSafeInteger(now) || now < 0) { this.setState(this.state, "unavailable"); return undefined; }
292
+ if (this.clockHighWater !== undefined && now < this.clockHighWater) { this.setState(this.state, "clock_rollback"); return undefined; }
293
+ this.clockHighWater = now;
294
+ return now;
295
+ }
296
+ /** Called only after the Store-backed sequence port committed a reservation. */
297
+ private clockRecoveredAfterWrite(): void {
298
+ if (this.cause === "clock_rollback" && this.observationTime() !== undefined) this.setState(this.state);
299
+ }
300
+ private refreshCodecCause(member: string): void {
301
+ const blocked = this.blockedCodec;
302
+ if (!blocked || blocked.member !== member) return;
303
+ const compatibility = this.codecState(member, blocked.feature);
304
+ if (compatibility === "supported") this.setState(this.state);
305
+ else this.setState(this.state, compatibility === "pending" ? "codec_pending" : "codec_unsupported");
306
+ }
307
+ private setState(state: RoomLinkState, cause?: RoomHealthCause): void {
308
+ if (cause !== "codec_pending" && cause !== "codec_unsupported") this.blockedCodec = undefined;
309
+ if (this.state === state && this.cause === cause) return;
310
+ this.state = state; this.cause = cause;
165
311
  try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
166
312
  }
167
- private current(session: Session, digest?: string): RoomState {
168
- if (!this.running || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
313
+ private current(session: Session, digest?: string, final = false): RoomState {
314
+ if ((!this.running && !(final && this.stopping)) || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
169
315
  let row: RoomState | undefined;
170
- try { row = this.options.store.get(this.options.name); } catch { fail("unavailable"); }
316
+ try { row = this.options.store.get(this.options.name); } catch (error) { this.setState(this.state, roomHealthCause(error)); fail("unavailable"); }
171
317
  if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
172
318
  return row;
173
319
  }
174
320
  private isCurrent(session: Session, digest: string): boolean {
175
- try { this.current(session, digest); return true; } catch { return false; }
321
+ try { if (this.stopping) return false; this.current(session, digest); return true; } catch { return false; }
176
322
  }
177
323
  private publicationCurrent(session: Session, digest: string): boolean {
178
324
  return session.phase === "ready" && session.roster?.digest === digest && this.isCurrent(session, digest);
@@ -215,7 +361,7 @@ export class RoomLink {
215
361
  }
216
362
  let peers: RoomPresencePeer[] | undefined;
217
363
  try { peers = presence(entry.plaintext, member.exposedPeers); } catch { /* a rotation may have removed an exposed id since the durable receive */ }
218
- if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers }); } catch { /* observation only */ } }
364
+ if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers, restored: true }); } catch { /* observation only */ } }
219
365
  }
220
366
  // Presence is ephemeral: stale exposure/member metadata is discarded, never
221
367
  // replayed after a rotation or left at the head of recovery forever.
@@ -225,6 +371,7 @@ export class RoomLink {
225
371
  }
226
372
  }
227
373
  private async deliverApplication(session: Session, digest: string, input: Parameters<RoomLinkApplication["receive"]>[1]): Promise<void> {
374
+ if (!this.running || this.stopping) fail("state_changed");
228
375
  try { await this.options.application?.receive(this.context(session, digest), input); }
229
376
  catch (error) { if (!(error instanceof RoomApplicationDeferredError)) throw error; }
230
377
  this.current(session, digest);
@@ -244,7 +391,7 @@ export class RoomLink {
244
391
  }).catch(() => undefined);
245
392
  return work;
246
393
  }
247
- private disconnect(session = this.session): void {
394
+ private disconnect(session = this.session, cause?: RoomHealthCause): void {
248
395
  if (session && session !== this.session) return;
249
396
  this.generation++;
250
397
  this.session = undefined;
@@ -254,16 +401,26 @@ export class RoomLink {
254
401
  this.republish = undefined;
255
402
  if (session) {
256
403
  clearTimeout(session.handshake);
404
+ if (session.refresh) clearTimeout(session.refresh);
257
405
  if (session.frameRetry) clearTimeout(session.frameRetry);
258
406
  session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
259
407
  session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
260
408
  try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
261
409
  }
262
- this.setState("offline");
410
+ this.setState("offline", cause);
263
411
  }
264
- private retry(session?: Session): void {
412
+ private retry(session?: Session, error?: unknown): void {
413
+ if (this.stopping) return;
265
414
  if (session && this.session !== session) return;
266
- this.disconnect(session);
415
+ let cause: RoomHealthCause = "transport_closed";
416
+ if (error instanceof RoomStoreError) cause = roomHealthCause(error);
417
+ else if (this.cause === "broker_refused" || this.cause === "clock_rollback") cause = this.cause;
418
+ else if (error instanceof RoomLinkError) {
419
+ if (error.code === "limit") cause = "backpressure";
420
+ else if (error.code === "invalid_wire") cause = "untrusted";
421
+ else cause = error.code;
422
+ } else if (this.cause === "backpressure") cause = this.cause;
423
+ this.disconnect(session, cause);
267
424
  if (!this.running || this.reconnect) return;
268
425
  const ceiling = Math.min(LIMITS.reconnectMaxMs, 1000 * 2 ** Math.min(this.attempts++, 9));
269
426
  const delay = Math.floor(ceiling / 2 + Math.random() * ceiling / 2);
@@ -273,48 +430,50 @@ export class RoomLink {
273
430
  private connect(): void {
274
431
  if (!this.running || this.session) return;
275
432
  let row: RoomState | undefined;
276
- try { row = this.options.store.get(this.options.name); } catch { this.setState("offline"); return; }
433
+ try { row = this.options.store.get(this.options.name); } catch (error) { this.retry(undefined, error); return; }
277
434
  if (!row || row.left) { this.setState("offline"); return; }
278
435
  let url: URL;
279
436
  try {
280
437
  url = new URL(brokerUrl(row.broker));
281
438
  if (url.pathname !== "/" || url.search) fail("untrusted");
282
- } catch { this.setState("offline"); return; }
439
+ } catch { this.setState("offline", "untrusted"); return; }
283
440
  url.pathname = `/v1/rooms/${row.current.room}`;
284
441
  let ws: WebSocket;
285
- // ponytail: negotiate only ephemeral here; replace this opt-in with #1204 T4 codec capabilities.
286
- try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
442
+ // Preserve the released ephemeral header exactly; old brokers ignore the additional codec token.
443
+ try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1", "x-agent-hub-codecs": BROKER_CODEC_FEATURES_OPT_IN } }); } catch { this.retry(); return; }
287
444
  ws.binaryType = "arraybuffer";
288
445
  const generation = ++this.generation;
289
446
  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,
290
447
  handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
291
448
  session.handshake.unref?.();
292
449
  this.session = session;
293
- this.setState("connecting");
450
+ this.setState("connecting", "connecting");
294
451
  ws.addEventListener("message", event => this.receive(session, event.data));
295
452
  ws.addEventListener("close", () => this.retry(session));
296
453
  ws.addEventListener("error", () => this.retry(session));
297
454
  }
298
455
  private send(session: Session, bytes: Uint8Array): void {
299
- this.current(session);
456
+ this.current(session, undefined, this.stopping);
300
457
  if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
301
458
  session.ws.send(bytes);
302
459
  }
303
- private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
304
- this.current(session);
460
+ private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string, codecFeatures = false, supported?: () => boolean, codecPending?: () => boolean, codecTarget?: { member: string; feature: RoomCodecFeature }): void {
461
+ this.current(session, undefined, this.stopping);
305
462
  // Only an unsent observation may be replaced. Never reorder an attempted frame.
306
463
  if (presence) {
307
- const at = session.outbound.findIndex((frame, index) => frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
464
+ const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && frame.presence && !!frame.codecFeatures === codecFeatures && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
308
465
  if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
309
466
  }
310
467
  if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
311
- session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
468
+ session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck, codecFeatures, supported, codecPending, codecTarget }); session.outboundBytes += wire.byteLength;
312
469
  this.pumpFrames(session);
313
470
  }
314
471
  private pumpFrames(session: Session): void {
315
472
  if (session.frameSent || session.frameRetry || !session.outbound.length) return;
316
- this.current(session);
473
+ this.current(session, undefined, this.stopping);
317
474
  const frame = session.outbound[0]!;
475
+ // An expired backoff timer is not proof that a pre-stop attempt was stored.
476
+ if (this.stopping && frame === session.closingHead) return;
318
477
  if (frame.retained && !frame.retained()) {
319
478
  // Disposed/expired intent cannot survive in the transport's RAM queue.
320
479
  session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
@@ -327,23 +486,39 @@ export class RoomLink {
327
486
  void this.publishPresence().catch(() => this.retry(session));
328
487
  return;
329
488
  }
489
+ if (frame.supported && !frame.supported()) {
490
+ this.blockedCodec = frame.codecTarget;
491
+ this.setState(this.state, frame.codecPending?.() ? "codec_pending" : "codec_unsupported");
492
+ if (frame.retained) {
493
+ // The durable intent owns retry. Withdraw only this unsent ciphertext so
494
+ // later receipts can progress; a fresh codec observation reseals its retry.
495
+ session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
496
+ this.pumpFrames(session);
497
+ }
498
+ return;
499
+ }
330
500
  // A foreground receipt follows the chat ACK on the socket. Otherwise broker
331
501
  // verification/storage of the receipt can pin the chat's one-packet slot.
332
502
  if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
333
503
  this.send(session, frame.wire); session.frameSent = true;
504
+ if (frame.codecTarget) this.refreshCodecCause(frame.codecTarget.member);
334
505
  const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
335
506
  }
336
507
  private storedFrame(session: Session, digest: string): void {
337
508
  if (session.outbound[0]?.digest !== digest) return;
338
509
  const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
510
+ if (session.closingHead === frame) session.closingHead = undefined;
339
511
  session.frameSent = false; session.retryAttempt = 0;
340
512
  if (session.frameRetry) clearTimeout(session.frameRetry);
341
513
  session.frameRetry = undefined;
514
+ if (this.cause === "backpressure") this.setState(this.state);
515
+ if (this.stoppedFrame?.digest === digest) this.stoppedFrame.resolve();
342
516
  this.pumpFrames(session);
343
517
  }
344
518
  private backoffFrame(session: Session, digest: string): void {
345
519
  if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
346
520
  session.frameSent = false;
521
+ this.setState(this.state, "backpressure");
347
522
  const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
348
523
  session.frameRetry = setTimeout(() => {
349
524
  session.frameRetry = undefined;
@@ -354,6 +529,22 @@ export class RoomLink {
354
529
  this.pumpFrames(session);
355
530
  }
356
531
  private receive(session: Session, data: unknown): void {
532
+ if (this.stopping && this.session === session) {
533
+ if (!(data instanceof ArrayBuffer) || data.byteLength > LIMITS.packetBytes) return;
534
+ try {
535
+ const row = exact(packet(new Uint8Array(data)), ["v", "type", "digest", "seq", "duplicate"]);
536
+ if (row.type === "stored" && typeof row.digest === "string" && DIGEST.test(row.digest)
537
+ && typeof row.seq === "string" && /^[1-9][0-9]{0,19}$/.test(row.seq) && typeof row.duplicate === "boolean") {
538
+ this.current(session, undefined, true);
539
+ if (this.options.store.receiptSendable(this.options.name, row.digest)) {
540
+ this.options.store.markStored(this.options.name, row.digest, row.seq);
541
+ this.options.store.retireStoredReceipt(this.options.name, row.digest);
542
+ }
543
+ this.storedFrame(session, row.digest);
544
+ }
545
+ } catch { /* Invalid controls cannot complete graceful shutdown. */ }
546
+ return;
547
+ }
357
548
  if (this.session !== session || !this.running) return;
358
549
  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; }
359
550
  const bytes = new Uint8Array(data).slice();
@@ -364,7 +555,7 @@ export class RoomLink {
364
555
  if (session.processing || this.session !== session || !session.queue.length) return;
365
556
  const bytes = session.queue.shift()!; session.queuedBytes -= bytes.length;
366
557
  session.processing = true;
367
- void this.handle(session, bytes).catch(() => this.retry(session)).finally(() => {
558
+ void this.handle(session, bytes).catch(error => this.retry(session, error)).finally(() => {
368
559
  session.processing = false;
369
560
  this.drain(session);
370
561
  });
@@ -404,8 +595,8 @@ export class RoomLink {
404
595
  return;
405
596
  }
406
597
  if (message.type === "ready") {
407
- const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
408
- if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
598
+ const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : []), ...(Object.hasOwn(message, "codecFeatures") ? ["codecFeatures", "codecSession"] : [])]), roster = session.roster;
599
+ if (ready.ephemeral !== undefined && ready.ephemeral !== true || ready.codecFeatures !== undefined && (ready.codecFeatures !== true || ready.ephemeral !== true || typeof ready.codecSession !== "string" || !DIGEST.test(ready.codecSession))) fail();
409
600
  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");
410
601
  if (roster.digest !== local.current.digest) {
411
602
  if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
@@ -413,15 +604,20 @@ export class RoomLink {
413
604
  this.send(session, local.wire); return;
414
605
  }
415
606
  if (session.presenceRoster !== roster.digest) {
416
- session.presenceRoster = roster.digest; session.pendingPresenceReplies.clear(); session.presenceAsked = false;
607
+ if (this.blockedCodec) this.setState(this.state);
608
+ session.presenceRoster = roster.digest; session.features = new Map(); session.codecPeers = new Map(); session.pendingPresence = new Map(); session.pendingPresenceReplies.clear(); session.presenceAsked = false;
417
609
  session.requestPresenceReply = true; session.lastPresence = undefined;
418
610
  }
419
611
  session.ephemeral = ready.ephemeral === true;
612
+ session.codecFeatures = ready.codecFeatures === true;
613
+ session.codecSession = typeof ready.codecSession === "string" ? ready.codecSession : undefined;
420
614
  session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
421
615
  if (session.deferredFrame) {
422
616
  session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
423
617
  }
618
+ if (!this.running || this.stopping) return;
424
619
  this.setState("online");
620
+ this.armPresenceRefresh(session);
425
621
  const context = this.context(session, local.current.digest);
426
622
  this.options.application?.members(context);
427
623
  this.restoreReceipts(session);
@@ -432,6 +628,33 @@ export class RoomLink {
432
628
  void this.publishPresence().catch(() => this.retry(session));
433
629
  return;
434
630
  }
631
+ if (message.type === "codec_peers") {
632
+ const inventory = exact(message, ["v", "type", "peers", "digest"]);
633
+ if (!Array.isArray(inventory.peers) || inventory.peers.length > 64 || typeof inventory.digest !== "string" || !DIGEST.test(inventory.digest)) fail("untrusted");
634
+ if (inventory.digest !== local.current.digest) return; // A previous-roster observation cannot revoke or grant current support.
635
+ if (!session.codecFeatures || session.phase !== "ready") fail("untrusted");
636
+ const members = roomMembers(local.current), seen = new Set<string>();
637
+ const peers = inventory.peers.map(value => {
638
+ const peer = exact(value, ["member", "session", "supported"]);
639
+ if (typeof peer.member !== "string" || !members.some(member => member.fingerprint === peer.member) || seen.has(peer.member)
640
+ || peer.member === this.options.store.identity.public.fingerprint || typeof peer.supported !== "boolean"
641
+ || peer.session !== null && (typeof peer.session !== "string" || !DIGEST.test(peer.session)) || peer.session === null && peer.supported) fail("untrusted");
642
+ seen.add(peer.member);
643
+ return { member: peer.member, session: peer.session as string | null, supported: peer.supported };
644
+ });
645
+ let advertise = false;
646
+ for (const peer of peers) {
647
+ const previous = session.codecPeers?.get(peer.member);
648
+ if (previous?.session === peer.session && previous.supported === peer.supported) continue;
649
+ (session.codecPeers ??= new Map()).set(peer.member, { session: peer.session, supported: peer.supported });
650
+ session.features?.delete(peer.member); session.pendingPresence?.delete(peer.member);
651
+ if (peer.supported && peer.session !== null) advertise = true;
652
+ this.refreshCodecCause(peer.member);
653
+ try { this.options.application?.codecsChanged?.(this.context(session, local.current.digest), { member: peer.member }); } catch { /* Compatibility evidence grants no authority. */ }
654
+ }
655
+ if (advertise) void this.publishCodecPresence(session).catch(error => this.retry(session, error));
656
+ return;
657
+ }
435
658
  if (message.type === "frame") {
436
659
  if (session.phase !== "ready") {
437
660
  // X2 may already have one ciphertext in flight while this verified owner
@@ -483,6 +706,7 @@ export class RoomLink {
483
706
  // X2 declares these refusal controls nonterminal. Their unsigned control
484
707
  // cannot settle any application; its local ACK deadline records uncertainty.
485
708
  if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
709
+ this.setState(this.state, "broker_refused");
486
710
  fail("unavailable");
487
711
  }
488
712
  fail();
@@ -511,9 +735,15 @@ export class RoomLink {
511
735
  if (!sender) return { status: "trust_changed" };
512
736
  let validPresence = false;
513
737
  try {
514
- if (packet(input.plaintext).type === "presence") { presence(input.plaintext, sender.exposedPeers); validPresence = true; }
738
+ if (packet(input.plaintext).type === "presence") {
739
+ presence(input.plaintext, sender.exposedPeers);
740
+ const features = packet(input.plaintext).features;
741
+ if (frame.codecFeatures === true ? !session.codecFeatures || features === undefined : features !== undefined) fail();
742
+ validPresence = true;
743
+ }
515
744
  else {
516
745
  invalidApplication = true;
746
+ if (frame.codecFeatures) fail("untrusted");
517
747
  if (!this.options.application) fail("untrusted");
518
748
  application = decodeRoomMessage(input.plaintext);
519
749
  if (application.type === "chat" && application.envelope.from !== "user" && !sender.exposedPeers.includes(application.envelope.from)) fail("untrusted");
@@ -562,7 +792,6 @@ export class RoomLink {
562
792
  this.options.store.settleInbox(this.options.name, acceptedDigest);
563
793
  if (result?.status === "accepted") this.capacityChanged(session, digest);
564
794
  } else if (result?.status === "accepted") {
565
- if (result.gap !== "0") try { this.options.application?.gap?.(this.context(session, digest), { sender: frame.sender, count: result.gap }); } catch { /* metadata observation cannot affect admission */ }
566
795
  const sender = roomMembers(latest.current).find(member => member.fingerprint === frame.sender);
567
796
  if (!sender) fail("untrusted");
568
797
  if (application && this.options.application) {
@@ -570,9 +799,35 @@ export class RoomLink {
570
799
  } else {
571
800
  const peers = presence(result.plaintext, sender.exposedPeers);
572
801
  this.current(session, digest);
573
- session.pendingPresenceReplies.delete(frame.sender);
574
- if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
575
- try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
802
+ if (frame.codecFeatures === true) {
803
+ const payload = packet(result.plaintext), peer = session.codecPeers?.get(frame.sender);
804
+ if (frame.epoch === latest.current.epoch && peer?.supported && peer.session !== null && payload.codecSession === peer.session) {
805
+ const features = roomCodecFeatures(payload.features), previous = session.features?.get(frame.sender);
806
+ const changed = ROOM_CODEC_FEATURES.filter(feature => previous === undefined || previous.session !== peer.session || previous.features.includes(feature) !== features.includes(feature));
807
+ if (!previous || previous.session !== peer.session || previous.features.length !== features.length || previous.features.some((feature, index) => feature !== features[index])) {
808
+ (session.features ??= new Map()).set(frame.sender, { session: peer.session, features });
809
+ }
810
+ this.refreshCodecCause(frame.sender);
811
+ const pending = session.pendingPresence?.get(frame.sender);
812
+ session.pendingPresence?.delete(frame.sender);
813
+ if (pending?.session === peer.session && pending.epoch === latest.current.epoch) {
814
+ try { this.options.onPresence?.({ member: frame.sender, peers: pending.peers, observedAt: pending.observedAt }); } catch { /* Observation only, with the original local receive time. */ }
815
+ }
816
+ this.pumpFrames(session);
817
+ if (changed.length) try { this.options.application?.codecsChanged?.(this.context(session, digest), { member: frame.sender, features: changed }); } catch { /* Compatibility observations cannot change admission. */ }
818
+ }
819
+ } else {
820
+ session.pendingPresenceReplies.delete(frame.sender);
821
+ if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
822
+ const peer = session.codecPeers?.get(frame.sender), advertisement = session.features?.get(frame.sender);
823
+ const knownLegacy = peer !== undefined && peer.session !== null && peer.supported === false;
824
+ if (session.codecFeatures && peers.some(peer => peer.state !== "offline") && !knownLegacy && (!peer?.session || advertisement?.session !== peer.session)) {
825
+ (session.pendingPresence ??= new Map()).set(frame.sender, { session: peer?.session ?? null, epoch: frame.epoch, peers, observedAt: this.observationTime() ?? null });
826
+ } else {
827
+ session.pendingPresence?.delete(frame.sender); // A newer negative or admitted observation supersedes any deferred live state.
828
+ try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
829
+ }
830
+ }
576
831
  this.current(session, digest);
577
832
  this.options.store.settleInbox(this.options.name, acceptedDigest);
578
833
  this.capacityChanged(session, digest);
@@ -602,6 +857,7 @@ export class RoomLink {
602
857
  this.pumpFrames(session);
603
858
  }
604
859
  private restoreReceipts(session: Session): void {
860
+ if (!this.running || this.stopping) return;
605
861
  this.current(session);
606
862
  this.options.store.expireReceipts(this.options.name);
607
863
  for (const entry of this.options.store.outbox(this.options.name)) {
@@ -632,12 +888,33 @@ export class RoomLink {
632
888
  this.sendTail = next.then(() => undefined, () => undefined);
633
889
  return next;
634
890
  }
891
+ /** Host-only admission pause; trust/current predicates remain independent. */
892
+ async quiesceApplications(): Promise<() => void> {
893
+ const token = Symbol(); this.applicationPauses.add(token);
894
+ await this.sendTail;
895
+ let released = false;
896
+ return () => { if (!released) { released = true; this.applicationPauses.delete(token); } };
897
+ }
635
898
  async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
636
899
  const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
637
900
  const session = this.session;
638
- if (!session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
901
+ if (this.stopping || !this.running || !session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
902
+ if (this.applicationPauses.size) { if (metadata) throw new RoomNotSentError(); fail("state_changed"); }
903
+ const required = roomRequiredCodecFeature(message);
904
+ if (required && !this.codecSupported(recipient, required)) throw this.codecRefusal(recipient, required);
639
905
  const digest = this.current(session).current.digest;
640
906
  const allowed = () => this.publicationCurrent(session, digest) && current();
907
+ const supported = () => required === undefined || this.codecSupported(recipient, required);
908
+ let codecLost = false;
909
+ const admitted = () => {
910
+ if (!supported()) { codecLost = true; return false; }
911
+ return this.applicationPauses.size === 0 && allowed();
912
+ };
913
+ const assertAdmitted = () => {
914
+ if (admitted()) return;
915
+ if (codecLost && required) throw this.codecRefusal(recipient, required);
916
+ fail("state_changed");
917
+ };
641
918
  let queueStarted = false;
642
919
  try {
643
920
  let presenceWork: Promise<void> | undefined;
@@ -653,7 +930,7 @@ export class RoomLink {
653
930
  // Reserve this chat's FIFO slot before yielding to the earlier observation.
654
931
  return await this.serialSend(async () => {
655
932
  if (presenceWork) await presenceWork;
656
- if (!allowed()) fail("state_changed");
933
+ assertAdmitted();
657
934
  if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
658
935
  || session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
659
936
  if (message.type === "receipt") {
@@ -667,13 +944,21 @@ export class RoomLink {
667
944
  const row = this.current(session, digest);
668
945
  if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
669
946
  let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
670
- if (!allowed()) fail("state_changed");
947
+ this.clockRecoveredAfterWrite();
948
+ assertAdmitted();
671
949
  let frameDigest: string;
672
950
  if (metadata) {
673
951
  queueStarted = true;
674
952
  let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
675
- try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, allowed); }
953
+ try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, admitted); }
676
954
  catch (error) {
955
+ if (codecLost && required && error instanceof RoomStoreError && error.code === "state_changed") {
956
+ // The atomic fence refused this attempt. Positively distinguish a
957
+ // never-admitted application from an earlier durable retry intent.
958
+ if (!this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw this.codecRefusal(recipient, required);
959
+ if (this.codecState(recipient, required) !== "unsupported") throw this.codecRefusal(recipient, required);
960
+ throw new RoomNotSentError();
961
+ }
677
962
  // An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
678
963
  if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
679
964
  && !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
@@ -682,7 +967,7 @@ export class RoomLink {
682
967
  frameDigest = record.frameDigest ?? record.digest;
683
968
  } else if (message.type === "receipt") {
684
969
  const ttlMs = this.options.application?.receiptTtlMs?.(this.context(session, digest)) ?? RECEIPT_TTL_MS;
685
- const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, allowed);
970
+ const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, admitted);
686
971
  if (!allowed()) fail("state_changed");
687
972
  if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
688
973
  frameDigest = receipt.digest; wire = receipt.wire;
@@ -692,7 +977,7 @@ export class RoomLink {
692
977
  this.queueFrame(session, wire, frameDigest, false, allowed, false,
693
978
  metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
694
979
  : message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
695
- undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
980
+ undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined, false, required ? supported : undefined, required ? () => this.codecState(recipient, required) === "pending" : undefined, required ? { member: recipient, feature: required } : undefined);
696
981
  } catch (error) {
697
982
  // The durable outbox owns retry after local backpressure; this is no uncertainty.
698
983
  if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
@@ -700,13 +985,53 @@ export class RoomLink {
700
985
  }
701
986
  return frameDigest;
702
987
  }); } catch (error) {
988
+ if (error instanceof RoomCodecUnsupportedError || error instanceof RoomCodecPendingError) throw error;
703
989
  if (metadata && !queueStarted) throw new RoomNotSentError();
704
990
  throw error;
705
991
  }
706
992
  }
993
+ /** A newly ready member may have missed our first advertisement. Repair it
994
+ * independently of legacy request/reply and snapshot dedupe, without a timer. */
995
+ private publishCodecPresence(session: Session): Promise<void> {
996
+ if (session.codecPublishing) { session.codecDirty = true; return session.codecPublishing; }
997
+ const work = (async () => {
998
+ do {
999
+ session.codecDirty = false;
1000
+ await this.serialSend(async () => {
1001
+ if (!this.running || this.stopping || this.session !== session || !session.codecFeatures || !session.codecSession || session.phase !== "ready") return;
1002
+ const row = this.current(session), identity = this.options.store.identity;
1003
+ if (!this.publicationCurrent(session, row.current.digest)) return;
1004
+ const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
1005
+ if (!own) fail("untrusted");
1006
+ const peers = this.currentPresence(own.exposedPeers), recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
1007
+ if (!recipients.length) return;
1008
+ const wire = await sealFrame({ epoch: row.current, identity, recipients,
1009
+ plaintext: canonicalBytes({ v: 1, type: "presence", peers, features: ROOM_CODEC_FEATURES, codecSession: session.codecSession }),
1010
+ ephemeral: true, codecFeatures: true, sequence: this.options.store.sequence });
1011
+ if (!this.publicationCurrent(session, row.current.digest)) return;
1012
+ const digest = await this.frameDigest(wire);
1013
+ this.queueFrame(session, wire, digest, false,
1014
+ () => this.publicationCurrent(session, row.current.digest) && peers.every(({ id }) => this.currentPresence(own.exposedPeers).some(value => value.id === id)),
1015
+ true, undefined, undefined, undefined, true);
1016
+ });
1017
+ } while (session.codecDirty && this.session === session && this.running && !this.stopping);
1018
+ })();
1019
+ session.codecPublishing = work;
1020
+ void work.finally(() => {
1021
+ if (session.codecPublishing !== work) return;
1022
+ session.codecPublishing = undefined;
1023
+ const again = session.codecDirty; session.codecDirty = false;
1024
+ // A peer event can arrive after the loop resolved but before this cleanup.
1025
+ // Consume that real event once, after releasing the old publication slot.
1026
+ if (again && this.session === session && this.running && !this.stopping && session.phase === "ready") {
1027
+ void this.publishCodecPresence(session).catch(error => this.retry(session, error));
1028
+ }
1029
+ }).catch(() => undefined);
1030
+ return work;
1031
+ }
707
1032
  async publishPresence(): Promise<void> {
708
1033
  const session = this.session;
709
- if (!session || session.phase !== "ready") return;
1034
+ if (!this.running || this.stopping || !session || session.phase !== "ready") return;
710
1035
  if (this.publishing) { this.publishDirty = true; return; }
711
1036
  // Keep the already scheduled coalesced snapshot. Cancelling it on every
712
1037
  // state flip turns an awaited burst back into one durable seal per call.
@@ -714,9 +1039,9 @@ export class RoomLink {
714
1039
  if (this.republish) clearTimeout(this.republish);
715
1040
  this.republish = undefined; this.publishDirty = false;
716
1041
  const forceRequest = this.forcePresenceRequest;
717
- const response = this.replyPresence && !forceRequest;
1042
+ const response = this.replyPresence;
718
1043
  if (response) this.replyPresence = false;
719
- this.forcePresenceRequest = false;
1044
+ this.forcePresenceRequest = response && forceRequest;
720
1045
  let sent = false;
721
1046
  this.publishing = true;
722
1047
  try {
@@ -732,7 +1057,20 @@ export class RoomLink {
732
1057
  const requestReply = !response && session.requestPresenceReply;
733
1058
  const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
734
1059
  if (!response && !forceRequest && session.lastPresence === presenceKey) return;
1060
+ // Positive codec evidence precedes the peer-state observation on this
1061
+ // FIFO lane. Observed idle never races its own pending advertisement.
1062
+ if (session.codecFeatures && session.codecSession) {
1063
+ const featureWire = await sealFrame({ epoch: row.current, identity, recipients,
1064
+ plaintext: canonicalBytes({ v: 1, type: "presence", peers, features: ROOM_CODEC_FEATURES, codecSession: session.codecSession }),
1065
+ ephemeral: true, codecFeatures: true, sequence: this.options.store.sequence });
1066
+ if (!this.publicationCurrent(session, row.current.digest)) return;
1067
+ const featureDigest = await this.frameDigest(featureWire);
1068
+ this.queueFrame(session, featureWire, featureDigest, false,
1069
+ () => this.publicationCurrent(session, row.current.digest) && peers.every(({ id }) => this.currentPresence(own.exposedPeers).some(value => value.id === id)),
1070
+ true, undefined, undefined, undefined, true);
1071
+ }
735
1072
  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 });
1073
+ this.clockRecoveredAfterWrite();
736
1074
  if (!this.publicationCurrent(session, row.current.digest)) return;
737
1075
  // Machine-local exposure can change during reserve/encryption/signing even
738
1076
  // when the signed roster is unchanged. Never emit an id it now withholds.
@@ -750,7 +1088,7 @@ export class RoomLink {
750
1088
  session.lastPresence = presenceKey; sent = true;
751
1089
  });
752
1090
  } catch (error) {
753
- this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
1091
+ this.retry(session, error); // Existing bounded reconnect, never a self-polling refresh retry.
754
1092
  throw error;
755
1093
  } finally {
756
1094
  this.publishing = false;