@staix/agent-hub 0.32.0 → 0.34.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 (58) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -2
  3. package/docs/design/bus.md +68 -2
  4. package/docs/dogfood.md +3 -1
  5. package/docs/memory.md +9 -3
  6. package/docs/operations.md +124 -20
  7. package/docs/quickstart.md +32 -2
  8. package/docs/security.md +28 -9
  9. package/docs/specs/2026-09-19-agent-hub-design.md +36 -6
  10. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  11. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  12. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  13. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  14. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  15. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  16. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  17. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  18. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  19. package/docs/verification/2026-11-07-0.34.0.md +71 -0
  20. package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
  21. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  22. package/docs/verified.json +11 -11
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +528 -206
  26. package/src/adapters/compat.ts +11 -11
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
  28. package/src/broker/protocol.ts +5 -0
  29. package/src/broker/server.ts +54 -4
  30. package/src/broker/store.ts +6 -3
  31. package/src/cli/autonomy-homes.ts +12 -3
  32. package/src/cli/claude-homes.ts +5 -5
  33. package/src/cli/claude-profile-hook.ts +38 -9
  34. package/src/cli/install-paths.ts +71 -0
  35. package/src/cli/main.ts +20 -1
  36. package/src/cli/output.ts +5 -1
  37. package/src/cli/room.ts +17 -5
  38. package/src/cli/runtime-version-warning.ts +8 -6
  39. package/src/cli/setup-homes.ts +13 -10
  40. package/src/cli/status-lines.ts +8 -1
  41. package/src/cli/terminal-recovery.ts +96 -18
  42. package/src/cli/upgrade-interactive.ts +29 -6
  43. package/src/cli/upgrade-output.ts +3 -2
  44. package/src/cli/upgrade-runtime.ts +34 -4
  45. package/src/cli/upgrade.ts +3 -1
  46. package/src/hub/bus.ts +7 -0
  47. package/src/hub/daemon.ts +13 -4
  48. package/src/hub/handoff.ts +1 -1
  49. package/src/rooms/application.ts +15 -0
  50. package/src/rooms/commands.ts +76 -16
  51. package/src/rooms/health.ts +32 -0
  52. package/src/rooms/link.ts +267 -43
  53. package/src/rooms/remote-peer.ts +18 -3
  54. package/src/rooms/service.ts +27 -12
  55. package/src/rooms/status.ts +57 -1
  56. package/src/rooms/store.ts +20 -1
  57. package/src/rooms/wire.ts +10 -8
  58. package/src/ui/index.html +1 -1
package/src/rooms/link.ts CHANGED
@@ -1,11 +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
7
  import { ROOM_STOP_TIMEOUT_MS, roomRefreshPeriod } from "./liveness.ts";
8
- import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.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";
9
10
 
10
11
  export type RoomLinkState = "offline" | "connecting" | "online";
11
12
  export type RoomPresenceState = "idle" | "busy" | "paused" | "offline";
@@ -14,7 +15,7 @@ export interface RoomLinkOptions {
14
15
  store: RoomStore;
15
16
  name: string;
16
17
  presence?: () => readonly { id: string; state: string }[];
17
- onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }) => void;
18
+ onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }) => void;
18
19
  onState?: (state: RoomLinkState) => void;
19
20
  application?: RoomLinkApplication;
20
21
  refreshMs?: number;
@@ -33,7 +34,7 @@ export interface RoomLinkApplication {
33
34
  transportAck?(context: RoomLinkContext, input: { digest: string }): void;
34
35
  receiptTtlMs?(context: RoomLinkContext): number;
35
36
  capacityChanged?(context: RoomLinkContext): void;
36
- gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
37
+ codecsChanged?(context: RoomLinkContext, input: { member: string; features?: readonly RoomCodecFeature[] }): void;
37
38
  }
38
39
  /** Host port signal: no application claim/effect occurred; the durable plaintext remains pending. */
39
40
  export class RoomApplicationDeferredError extends Error {
@@ -45,17 +46,32 @@ export class RoomLinkError extends Error {
45
46
  }
46
47
  }
47
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
+ }
48
56
  export class RoomNotSentError extends Error {
49
57
  readonly code = "not_sent";
50
58
  constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
51
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";
52
68
  const RECEIPT_TTL_MS = 24 * 3_600_000;
53
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 });
54
70
  const decoder = new TextDecoder("utf-8", { fatal: true });
55
71
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
56
72
  const DIGEST = /^[A-Za-z0-9_-]{43}$/;
57
73
  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 }
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 } }
59
75
  interface Session {
60
76
  closingHead?: PendingFrame;
61
77
  refresh?: ReturnType<typeof setTimeout>;
@@ -72,6 +88,13 @@ interface Session {
72
88
  frameRetry?: ReturnType<typeof setTimeout>;
73
89
  lastPresence?: string;
74
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[] }>;
75
98
  ws: WebSocket;
76
99
  generation: number;
77
100
  room: string;
@@ -106,7 +129,8 @@ function packet(bytes: Uint8Array): Record<string, unknown> {
106
129
  return row;
107
130
  }
108
131
  function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePeer[] {
109
- 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(); }
110
134
  if (row.requestReply !== undefined && typeof row.requestReply !== "boolean") fail();
111
135
  if (row.type !== "presence" || !Array.isArray(row.peers) || row.peers.length > 64) fail();
112
136
  const peers = row.peers.map(value => {
@@ -131,6 +155,9 @@ export class RoomLink {
131
155
  private reconnect?: ReturnType<typeof setTimeout>;
132
156
  private attempts = 0;
133
157
  private state: RoomLinkState = "offline";
158
+ private cause?: RoomHealthCause;
159
+ private blockedCodec?: { member: string; feature: RoomCodecFeature };
160
+ private clockHighWater?: number;
134
161
  private publishing = false;
135
162
  private publishDirty = false;
136
163
  private replyPresence = false;
@@ -144,13 +171,33 @@ export class RoomLink {
144
171
 
145
172
  /** Advisory transport state without copying or decoding a room epoch for a status poll. */
146
173
  get connectionState(): RoomLinkState { return this.state; }
174
+ get connectionCause(): RoomHealthCause | undefined { return this.cause; }
147
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
+ }
148
194
  get livenessTracked(): boolean | undefined { return this.session?.phase === "ready" ? this.session.ephemeral : undefined; }
149
- status(): { state: RoomLinkState; members: number } {
195
+ status(): { state: RoomLinkState; members: number; cause?: RoomHealthCause; nextStep?: string } {
150
196
  try {
151
197
  const row = this.options.store.get(this.options.name);
152
- return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0 };
153
- } 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) }; }
154
201
  }
155
202
  async start(): Promise<void> {
156
203
  if (this.running || this.stopping) return;
@@ -224,12 +271,12 @@ export class RoomLink {
224
271
  if (session.refresh) clearTimeout(session.refresh);
225
272
  // Legacy brokers retain state-change/dedupe publication without periodic traffic.
226
273
  if (!session.ephemeral || !this.running || this.stopping) return;
227
- session.refreshedAt = (this.options.now ?? Date.now)();
274
+ session.refreshedAt = this.observationTime();
228
275
  session.refresh = setTimeout(() => {
229
276
  session.refresh = undefined;
230
277
  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) {
278
+ const now = this.observationTime();
279
+ if (now !== undefined && session.refreshedAt !== undefined && now - session.refreshedAt >= this.refreshMs) {
233
280
  session.pendingPresenceReplies.clear(); session.presenceAsked = false;
234
281
  session.requestPresenceReply = true; this.forcePresenceRequest = true;
235
282
  void this.publishPresence().catch(() => this.retry(session));
@@ -238,15 +285,35 @@ export class RoomLink {
238
285
  }, this.refreshMs);
239
286
  session.refresh.unref?.();
240
287
  }
241
- private setState(state: RoomLinkState): void {
242
- if (this.state === state) return;
243
- this.state = state;
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;
244
311
  try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
245
312
  }
246
313
  private current(session: Session, digest?: string, final = false): RoomState {
247
314
  if ((!this.running && !(final && this.stopping)) || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
248
315
  let row: RoomState | undefined;
249
- 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"); }
250
317
  if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
251
318
  return row;
252
319
  }
@@ -324,7 +391,7 @@ export class RoomLink {
324
391
  }).catch(() => undefined);
325
392
  return work;
326
393
  }
327
- private disconnect(session = this.session): void {
394
+ private disconnect(session = this.session, cause?: RoomHealthCause): void {
328
395
  if (session && session !== this.session) return;
329
396
  this.generation++;
330
397
  this.session = undefined;
@@ -340,12 +407,20 @@ export class RoomLink {
340
407
  session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
341
408
  try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
342
409
  }
343
- this.setState("offline");
410
+ this.setState("offline", cause);
344
411
  }
345
- private retry(session?: Session): void {
412
+ private retry(session?: Session, error?: unknown): void {
346
413
  if (this.stopping) return;
347
414
  if (session && this.session !== session) return;
348
- 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);
349
424
  if (!this.running || this.reconnect) return;
350
425
  const ceiling = Math.min(LIMITS.reconnectMaxMs, 1000 * 2 ** Math.min(this.attempts++, 9));
351
426
  const delay = Math.floor(ceiling / 2 + Math.random() * ceiling / 2);
@@ -355,24 +430,24 @@ export class RoomLink {
355
430
  private connect(): void {
356
431
  if (!this.running || this.session) return;
357
432
  let row: RoomState | undefined;
358
- 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; }
359
434
  if (!row || row.left) { this.setState("offline"); return; }
360
435
  let url: URL;
361
436
  try {
362
437
  url = new URL(brokerUrl(row.broker));
363
438
  if (url.pathname !== "/" || url.search) fail("untrusted");
364
- } catch { this.setState("offline"); return; }
439
+ } catch { this.setState("offline", "untrusted"); return; }
365
440
  url.pathname = `/v1/rooms/${row.current.room}`;
366
441
  let ws: WebSocket;
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; }
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; }
369
444
  ws.binaryType = "arraybuffer";
370
445
  const generation = ++this.generation;
371
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,
372
447
  handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
373
448
  session.handshake.unref?.();
374
449
  this.session = session;
375
- this.setState("connecting");
450
+ this.setState("connecting", "connecting");
376
451
  ws.addEventListener("message", event => this.receive(session, event.data));
377
452
  ws.addEventListener("close", () => this.retry(session));
378
453
  ws.addEventListener("error", () => this.retry(session));
@@ -382,15 +457,15 @@ export class RoomLink {
382
457
  if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
383
458
  session.ws.send(bytes);
384
459
  }
385
- private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
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 {
386
461
  this.current(session, undefined, this.stopping);
387
462
  // Only an unsent observation may be replaced. Never reorder an attempted frame.
388
463
  if (presence) {
389
- const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && 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));
390
465
  if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
391
466
  }
392
467
  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;
468
+ session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck, codecFeatures, supported, codecPending, codecTarget }); session.outboundBytes += wire.byteLength;
394
469
  this.pumpFrames(session);
395
470
  }
396
471
  private pumpFrames(session: Session): void {
@@ -411,10 +486,22 @@ export class RoomLink {
411
486
  void this.publishPresence().catch(() => this.retry(session));
412
487
  return;
413
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
+ }
414
500
  // A foreground receipt follows the chat ACK on the socket. Otherwise broker
415
501
  // verification/storage of the receipt can pin the chat's one-packet slot.
416
502
  if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
417
503
  this.send(session, frame.wire); session.frameSent = true;
504
+ if (frame.codecTarget) this.refreshCodecCause(frame.codecTarget.member);
418
505
  const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
419
506
  }
420
507
  private storedFrame(session: Session, digest: string): void {
@@ -424,12 +511,14 @@ export class RoomLink {
424
511
  session.frameSent = false; session.retryAttempt = 0;
425
512
  if (session.frameRetry) clearTimeout(session.frameRetry);
426
513
  session.frameRetry = undefined;
514
+ if (this.cause === "backpressure") this.setState(this.state);
427
515
  if (this.stoppedFrame?.digest === digest) this.stoppedFrame.resolve();
428
516
  this.pumpFrames(session);
429
517
  }
430
518
  private backoffFrame(session: Session, digest: string): void {
431
519
  if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
432
520
  session.frameSent = false;
521
+ this.setState(this.state, "backpressure");
433
522
  const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
434
523
  session.frameRetry = setTimeout(() => {
435
524
  session.frameRetry = undefined;
@@ -466,7 +555,7 @@ export class RoomLink {
466
555
  if (session.processing || this.session !== session || !session.queue.length) return;
467
556
  const bytes = session.queue.shift()!; session.queuedBytes -= bytes.length;
468
557
  session.processing = true;
469
- void this.handle(session, bytes).catch(() => this.retry(session)).finally(() => {
558
+ void this.handle(session, bytes).catch(error => this.retry(session, error)).finally(() => {
470
559
  session.processing = false;
471
560
  this.drain(session);
472
561
  });
@@ -506,8 +595,8 @@ export class RoomLink {
506
595
  return;
507
596
  }
508
597
  if (message.type === "ready") {
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();
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();
511
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");
512
601
  if (roster.digest !== local.current.digest) {
513
602
  if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
@@ -515,17 +604,20 @@ export class RoomLink {
515
604
  this.send(session, local.wire); return;
516
605
  }
517
606
  if (session.presenceRoster !== roster.digest) {
518
- 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;
519
609
  session.requestPresenceReply = true; session.lastPresence = undefined;
520
610
  }
521
611
  session.ephemeral = ready.ephemeral === true;
612
+ session.codecFeatures = ready.codecFeatures === true;
613
+ session.codecSession = typeof ready.codecSession === "string" ? ready.codecSession : undefined;
522
614
  session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
523
615
  if (session.deferredFrame) {
524
616
  session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
525
617
  }
526
618
  if (!this.running || this.stopping) return;
527
- this.armPresenceRefresh(session);
528
619
  this.setState("online");
620
+ this.armPresenceRefresh(session);
529
621
  const context = this.context(session, local.current.digest);
530
622
  this.options.application?.members(context);
531
623
  this.restoreReceipts(session);
@@ -536,6 +628,33 @@ export class RoomLink {
536
628
  void this.publishPresence().catch(() => this.retry(session));
537
629
  return;
538
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
+ }
539
658
  if (message.type === "frame") {
540
659
  if (session.phase !== "ready") {
541
660
  // X2 may already have one ciphertext in flight while this verified owner
@@ -587,6 +706,7 @@ export class RoomLink {
587
706
  // X2 declares these refusal controls nonterminal. Their unsigned control
588
707
  // cannot settle any application; its local ACK deadline records uncertainty.
589
708
  if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
709
+ this.setState(this.state, "broker_refused");
590
710
  fail("unavailable");
591
711
  }
592
712
  fail();
@@ -615,9 +735,15 @@ export class RoomLink {
615
735
  if (!sender) return { status: "trust_changed" };
616
736
  let validPresence = false;
617
737
  try {
618
- 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
+ }
619
744
  else {
620
745
  invalidApplication = true;
746
+ if (frame.codecFeatures) fail("untrusted");
621
747
  if (!this.options.application) fail("untrusted");
622
748
  application = decodeRoomMessage(input.plaintext);
623
749
  if (application.type === "chat" && application.envelope.from !== "user" && !sender.exposedPeers.includes(application.envelope.from)) fail("untrusted");
@@ -666,7 +792,6 @@ export class RoomLink {
666
792
  this.options.store.settleInbox(this.options.name, acceptedDigest);
667
793
  if (result?.status === "accepted") this.capacityChanged(session, digest);
668
794
  } else if (result?.status === "accepted") {
669
- 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 */ }
670
795
  const sender = roomMembers(latest.current).find(member => member.fingerprint === frame.sender);
671
796
  if (!sender) fail("untrusted");
672
797
  if (application && this.options.application) {
@@ -674,9 +799,35 @@ export class RoomLink {
674
799
  } else {
675
800
  const peers = presence(result.plaintext, sender.exposedPeers);
676
801
  this.current(session, digest);
677
- session.pendingPresenceReplies.delete(frame.sender);
678
- if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
679
- 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
+ }
680
831
  this.current(session, digest);
681
832
  this.options.store.settleInbox(this.options.name, acceptedDigest);
682
833
  this.capacityChanged(session, digest);
@@ -749,9 +900,21 @@ export class RoomLink {
749
900
  const session = this.session;
750
901
  if (this.stopping || !this.running || !session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
751
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);
752
905
  const digest = this.current(session).current.digest;
753
906
  const allowed = () => this.publicationCurrent(session, digest) && current();
754
- const admitted = () => this.applicationPauses.size === 0 && allowed();
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
+ };
755
918
  let queueStarted = false;
756
919
  try {
757
920
  let presenceWork: Promise<void> | undefined;
@@ -767,7 +930,7 @@ export class RoomLink {
767
930
  // Reserve this chat's FIFO slot before yielding to the earlier observation.
768
931
  return await this.serialSend(async () => {
769
932
  if (presenceWork) await presenceWork;
770
- if (!admitted()) fail("state_changed");
933
+ assertAdmitted();
771
934
  if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
772
935
  || session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
773
936
  if (message.type === "receipt") {
@@ -781,13 +944,21 @@ export class RoomLink {
781
944
  const row = this.current(session, digest);
782
945
  if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
783
946
  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");
947
+ this.clockRecoveredAfterWrite();
948
+ assertAdmitted();
785
949
  let frameDigest: string;
786
950
  if (metadata) {
787
951
  queueStarted = true;
788
952
  let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
789
953
  try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, admitted); }
790
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
+ }
791
962
  // An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
792
963
  if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
793
964
  && !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
@@ -806,7 +977,7 @@ export class RoomLink {
806
977
  this.queueFrame(session, wire, frameDigest, false, allowed, false,
807
978
  metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
808
979
  : message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
809
- 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);
810
981
  } catch (error) {
811
982
  // The durable outbox owns retry after local backpressure; this is no uncertainty.
812
983
  if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
@@ -814,10 +985,50 @@ export class RoomLink {
814
985
  }
815
986
  return frameDigest;
816
987
  }); } catch (error) {
988
+ if (error instanceof RoomCodecUnsupportedError || error instanceof RoomCodecPendingError) throw error;
817
989
  if (metadata && !queueStarted) throw new RoomNotSentError();
818
990
  throw error;
819
991
  }
820
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
+ }
821
1032
  async publishPresence(): Promise<void> {
822
1033
  const session = this.session;
823
1034
  if (!this.running || this.stopping || !session || session.phase !== "ready") return;
@@ -846,7 +1057,20 @@ export class RoomLink {
846
1057
  const requestReply = !response && session.requestPresenceReply;
847
1058
  const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
848
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
+ }
849
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();
850
1074
  if (!this.publicationCurrent(session, row.current.digest)) return;
851
1075
  // Machine-local exposure can change during reserve/encryption/signing even
852
1076
  // when the signed roster is unchanged. Never emit an id it now withholds.
@@ -864,7 +1088,7 @@ export class RoomLink {
864
1088
  session.lastPresence = presenceKey; sent = true;
865
1089
  });
866
1090
  } catch (error) {
867
- 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.
868
1092
  throw error;
869
1093
  } finally {
870
1094
  this.publishing = false;