@staix/agent-hub 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
@@ -1,6 +1,7 @@
1
+ import { reviewCaptureExcluded } from "../hub/memory-policy.ts";
1
2
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
3
  import { defaultCapturePeer } from "../adapters/definitions/native-identity-defaults.ts";
3
- import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
4
+ import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, QUESTION_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
4
5
  import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
5
6
  import { isDenied } from "../local/deny.ts";
6
7
  import type { MemoryClient } from "./client.ts";
@@ -38,7 +39,9 @@ export class Capture {
38
39
  }
39
40
 
40
41
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
41
- if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || this.opts.skip?.includes(call.tool)) return;
42
+ if (!this.sessionId || reviewCaptureExcluded(call.tool, call.args) || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_evidence_record" || this.opts.skip?.includes(call.tool)) return;
43
+ // #1053: a question and its answer are the person's and the peer's words: never memory evidence, whatever the call's paths say.
44
+ if (QUESTION_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) return;
42
45
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
43
46
  if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
44
47
  // ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
package/src/pet/config.ts CHANGED
@@ -8,7 +8,14 @@ const PALETTES: Record<string, PetPalette> = { a: "blue", b: "teal", c: "violet"
8
8
  export function petPalette(value: unknown): PetPalette | undefined {
9
9
  return typeof value === "string" ? PALETTES[value.toLowerCase()] : undefined;
10
10
  }
11
- export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string; window?: boolean }
11
+ export const PET_WINDOW_RETIRED_NOTICE = "The floating pet window moved to acon Mini; run acon ui and open Mini.";
12
+ /** Source compatibility only. Removed window data is never a terminal option or a settings capability. */
13
+ export function stripLegacyPetWindow(value: unknown): unknown {
14
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "window")) return value;
15
+ const { window: _retired, ...terminal } = value as Record<string, unknown>;
16
+ return terminal;
17
+ }
18
+ export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string }
12
19
  /** Console-local presentation only; it merges pet.palette into config.local.json and grants no authority. */
13
20
  export function savePetPalette(cwd: string, value: string): string {
14
21
  const palette = petPalette(value);
@@ -35,34 +42,39 @@ export function savePetPalette(cwd: string, value: string): string {
35
42
  /** Console-local presentation only; this reader never writes config or grants authority. */
36
43
  export function loadPetConfig(cwd: string): PetConfig {
37
44
  let value: unknown;
38
- let enabled = true;
45
+ let enabled = true, retiredWindow = false;
39
46
  for (const name of ["config.json", "config.local.json"]) {
40
47
  try {
41
48
  const file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
42
- // A window-only local opt-in does not undo inherited pet:false presentation.
43
- if (file.pet === false) enabled = false;
44
- else if (file.pet !== undefined && !(file.pet && typeof file.pet === "object" && !Array.isArray(file.pet) && Object.keys(file.pet).length === 1 && Object.hasOwn(file.pet, "window"))) enabled = true;
45
- if (file.pet !== undefined) value = value && typeof value === "object" && file.pet && typeof file.pet === "object" ? { ...value, ...file.pet } : file.pet;
49
+ const pet = stripLegacyPetWindow(file.pet);
50
+ if (pet !== file.pet) {
51
+ retiredWindow = true;
52
+ // A retired-only block contributes no terminal override, including no enablement over inherited pet:false.
53
+ if (pet && typeof pet === "object" && !Object.keys(pet).length) continue;
54
+ }
55
+ if (pet === false) enabled = false;
56
+ else if (pet !== undefined) enabled = true;
57
+ if (pet !== undefined) value = value && typeof value === "object" && pet && typeof pet === "object" ? { ...value, ...pet } : pet;
46
58
  } catch (error) {
47
59
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
48
60
  }
49
61
  }
50
- if (value === false) return { enabled: false, blink: true, bell: false };
62
+ const notice = (other?: string) => [retiredWindow ? PET_WINDOW_RETIRED_NOTICE : undefined, other].filter(Boolean).join(" ");
63
+ if (value === false) return { enabled: false, blink: true, bell: false, ...(retiredWindow ? { notice: notice() } : {}) };
51
64
  const defaults = { enabled: true, blink: true, bell: false };
52
- if (value === undefined) return defaults;
53
- if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: "Unknown pet configuration; using stai line defaults." };
65
+ if (value === undefined) return { ...defaults, ...(retiredWindow ? { notice: notice() } : {}) };
66
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: notice("Unknown pet configuration; using stai line defaults.") };
54
67
  const row = value as Record<string, unknown>;
55
- const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette", "window"].includes(key)) ||
68
+ const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette"].includes(key)) ||
56
69
  row.character !== undefined && !characterId(row.character) || row.size !== undefined && !["line", "mini", "full"].includes(row.size as string) ||
57
70
  row.glyphs !== undefined && (typeof row.glyphs !== "string" || !["auto", "ascii", "blocks"].includes(row.glyphs)) ||
58
71
  row.palette !== undefined && !petPalette(row.palette) ||
59
- row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean" || row.window !== undefined && typeof row.window !== "boolean";
72
+ row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean";
60
73
  const palette = petPalette(row.palette);
61
74
  return { enabled, blink: typeof row.blink === "boolean" ? row.blink : true, bell: typeof row.bell === "boolean" ? row.bell : false,
62
- ...(typeof row.window === "boolean" ? { window: row.window } : {}),
63
75
  ...(characterId(row.character) && row.character !== "stai" ? { character: row.character } : {}),
64
76
  ...(row.size === "mini" || row.size === "full" ? { size: row.size } : {}),
65
77
  ...(row.glyphs === "ascii" || row.glyphs === "blocks" ? { glyphs: row.glyphs } : {}),
66
78
  ...(palette && palette !== "teal" ? { palette } : {}),
67
- ...(invalid ? { notice: "Unknown pet option; using stai line defaults for that option." } : {}) };
79
+ ...(invalid || retiredWindow ? { notice: notice(invalid ? "Unknown pet option; using stai line defaults for that option." : undefined) } : {}) };
68
80
  }
package/src/rooms/link.ts CHANGED
@@ -3,7 +3,7 @@ import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "..
3
3
  import { assertRoomCryptoReady, encodeBase64url, sha256, RoomCryptoError } from "./crypto.ts";
4
4
  import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, openFrame, roomMembers, sealFrame, verifyPublicRoster, RoomWireError, type FrameWire, type FrameOpenResult, type VerifiedPublicRoster } from "./wire.ts";
5
5
  import type { RoomRefusalReason, RoomState, RoomStore } from "./store.ts";
6
- import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, type RoomOutgoingInput } from "./store.ts";
6
+ import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, RoomStoreError, type RoomOutgoingInput } from "./store.ts";
7
7
  import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.ts";
8
8
 
9
9
  export type RoomLinkState = "offline" | "connecting" | "online";
@@ -28,6 +28,7 @@ export interface RoomLinkApplication {
28
28
  stored(context: RoomLinkContext, input: { digest: string; seq: string }): void;
29
29
  /** Fixed transport metadata only; never application completion or authority. */
30
30
  transportAck?(context: RoomLinkContext, input: { digest: string }): void;
31
+ receiptTtlMs?(context: RoomLinkContext): number;
31
32
  capacityChanged?(context: RoomLinkContext): void;
32
33
  gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
33
34
  }
@@ -40,12 +41,31 @@ export class RoomLinkError extends Error {
40
41
  super(`Room link: ${code}`); this.name = "RoomLinkError";
41
42
  }
42
43
  }
44
+ /** Only before durable application enqueue: the Bus may retain its original queue item. */
45
+ export class RoomNotSentError extends Error {
46
+ readonly code = "not_sent";
47
+ constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
48
+ }
49
+ const RECEIPT_TTL_MS = 24 * 3_600_000;
43
50
  const LIMITS = Object.freeze({ packets: 8, queuedBytes: 2_097_152, packetBytes: 1_048_576, backpressure: 1_048_576, handshakeMs: 30_000, reconnectMaxMs: 300_000 });
44
51
  const decoder = new TextDecoder("utf-8", { fatal: true });
45
52
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
46
53
  const DIGEST = /^[A-Za-z0-9_-]{43}$/;
47
54
  const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
55
+ interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
48
56
  interface Session {
57
+ processingFrame?: string;
58
+ acknowledgedFrame?: string;
59
+ pendingPresenceReplies: Set<string>;
60
+ presenceAsked: boolean;
61
+ presenceRoster?: string;
62
+ outbound: PendingFrame[];
63
+ outboundBytes: number;
64
+ frameSent: boolean;
65
+ retryAttempt: number;
66
+ frameRetry?: ReturnType<typeof setTimeout>;
67
+ lastPresence?: string;
68
+ ephemeral?: boolean;
49
69
  ws: WebSocket;
50
70
  generation: number;
51
71
  room: string;
@@ -165,6 +185,7 @@ export class RoomLink {
165
185
  if (!this.publicationCurrent(session, row.current.digest) || !this.options.store.ordinaryInboxAvailable(this.options.name, bytes)) return;
166
186
  // Intent survives coalescing, ordinary responses and publication failure.
167
187
  // Only an actually sent request with current capacity may clear the numeric flag.
188
+ if (!this.forcePresenceRequest) { session.pendingPresenceReplies.clear(); session.presenceAsked = false; }
168
189
  session.requestPresenceReply = true; this.forcePresenceRequest = true;
169
190
  try { await this.publishPresence(); }
170
191
  catch (error) { this.retry(session); throw error; }
@@ -233,6 +254,8 @@ export class RoomLink {
233
254
  this.republish = undefined;
234
255
  if (session) {
235
256
  clearTimeout(session.handshake);
257
+ if (session.frameRetry) clearTimeout(session.frameRetry);
258
+ session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
236
259
  session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
237
260
  try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
238
261
  }
@@ -259,10 +282,11 @@ export class RoomLink {
259
282
  } catch { this.setState("offline"); return; }
260
283
  url.pathname = `/v1/rooms/${row.current.room}`;
261
284
  let ws: WebSocket;
262
- try { ws = new WebSocket(url.href); } catch { this.retry(); return; }
285
+ // ponytail: negotiate only ephemeral here; replace this opt-in with #1204 T4 codec capabilities.
286
+ try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
263
287
  ws.binaryType = "arraybuffer";
264
288
  const generation = ++this.generation;
265
- const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
289
+ const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", pendingPresenceReplies: new Set(), presenceAsked: false, outbound: [], outboundBytes: 0, frameSent: false, retryAttempt: 0, queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
266
290
  handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
267
291
  session.handshake.unref?.();
268
292
  this.session = session;
@@ -276,6 +300,59 @@ export class RoomLink {
276
300
  if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
277
301
  session.ws.send(bytes);
278
302
  }
303
+ private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
304
+ this.current(session);
305
+ // Only an unsent observation may be replaced. Never reorder an attempted frame.
306
+ if (presence) {
307
+ const at = session.outbound.findIndex((frame, index) => frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
308
+ if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
309
+ }
310
+ if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
311
+ session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
312
+ this.pumpFrames(session);
313
+ }
314
+ private pumpFrames(session: Session): void {
315
+ if (session.frameSent || session.frameRetry || !session.outbound.length) return;
316
+ this.current(session);
317
+ const frame = session.outbound[0]!;
318
+ if (frame.retained && !frame.retained()) {
319
+ // Disposed/expired intent cannot survive in the transport's RAM queue.
320
+ session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
321
+ this.pumpFrames(session); return;
322
+ }
323
+ if (!frame.current()) {
324
+ if (!frame.presence) fail("state_changed");
325
+ session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.lastPresence = undefined;
326
+ this.pumpFrames(session);
327
+ void this.publishPresence().catch(() => this.retry(session));
328
+ return;
329
+ }
330
+ // A foreground receipt follows the chat ACK on the socket. Otherwise broker
331
+ // verification/storage of the receipt can pin the chat's one-packet slot.
332
+ if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
333
+ this.send(session, frame.wire); session.frameSent = true;
334
+ const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
335
+ }
336
+ private storedFrame(session: Session, digest: string): void {
337
+ if (session.outbound[0]?.digest !== digest) return;
338
+ const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
339
+ session.frameSent = false; session.retryAttempt = 0;
340
+ if (session.frameRetry) clearTimeout(session.frameRetry);
341
+ session.frameRetry = undefined;
342
+ this.pumpFrames(session);
343
+ }
344
+ private backoffFrame(session: Session, digest: string): void {
345
+ if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
346
+ session.frameSent = false;
347
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
348
+ session.frameRetry = setTimeout(() => {
349
+ session.frameRetry = undefined;
350
+ if (this.session !== session || !this.running || session.phase !== "ready") return;
351
+ try { this.pumpFrames(session); } catch { this.retry(session); }
352
+ }, delay);
353
+ session.frameRetry.unref?.();
354
+ this.pumpFrames(session);
355
+ }
279
356
  private receive(session: Session, data: unknown): void {
280
357
  if (this.session !== session || !this.running) return;
281
358
  if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > LIMITS.packetBytes || session.queue.length + (session.deferredFrame ? 1 : 0) >= LIMITS.packets || session.queuedBytes + (session.deferredFrame?.byteLength ?? 0) + data.byteLength > LIMITS.queuedBytes) { this.retry(session); return; }
@@ -327,13 +404,19 @@ export class RoomLink {
327
404
  return;
328
405
  }
329
406
  if (message.type === "ready") {
330
- const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest"]), roster = session.roster;
407
+ const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
408
+ if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
331
409
  if (!roster || ready.room !== local.current.room || ready.member !== this.options.store.identity.public.fingerprint || ready.epoch !== roster.epoch || ready.digest !== roster.digest) fail("untrusted");
332
410
  if (roster.digest !== local.current.digest) {
333
411
  if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
334
412
  session.ownerUpgrading = true;
335
413
  this.send(session, local.wire); return;
336
414
  }
415
+ if (session.presenceRoster !== roster.digest) {
416
+ session.presenceRoster = roster.digest; session.pendingPresenceReplies.clear(); session.presenceAsked = false;
417
+ session.requestPresenceReply = true; session.lastPresence = undefined;
418
+ }
419
+ session.ephemeral = ready.ephemeral === true;
337
420
  session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
338
421
  if (session.deferredFrame) {
339
422
  session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
@@ -341,6 +424,7 @@ export class RoomLink {
341
424
  this.setState("online");
342
425
  const context = this.context(session, local.current.digest);
343
426
  this.options.application?.members(context);
427
+ this.restoreReceipts(session);
344
428
  await this.retryPendingApplications();
345
429
  this.current(session, local.current.digest);
346
430
  await this.options.application?.online(context);
@@ -365,7 +449,14 @@ export class RoomLink {
365
449
  if (message.type === "stored") {
366
450
  const row = exact(message, ["v", "type", "digest", "seq", "duplicate"]);
367
451
  if (typeof row.digest !== "string" || !DIGEST.test(row.digest) || typeof row.seq !== "string" || !/^[1-9][0-9]{0,19}$/.test(row.seq) || typeof row.duplicate !== "boolean") fail();
452
+ const receipt = this.options.store.receiptSendable(this.options.name, row.digest);
453
+ if (receipt) {
454
+ this.options.store.markStored(this.options.name, row.digest, row.seq);
455
+ this.options.store.retireStoredReceipt(this.options.name, row.digest);
456
+ }
457
+ this.storedFrame(session, row.digest);
368
458
  this.options.application?.stored(this.context(session, local.current.digest), { digest: row.digest, seq: row.seq });
459
+ this.restoreReceipts(session);
369
460
  return; // Broker storage is not a signed recipient application-completion receipt.
370
461
  }
371
462
  if (message.type === "queue_ack") {
@@ -381,10 +472,17 @@ export class RoomLink {
381
472
  return;
382
473
  }
383
474
  if (message.type === "error") {
384
- const error = exact(message, ["v", "type", "code"]);
475
+ const error = exact(message, Object.hasOwn(message, "digest") ? ["v", "type", "code", "digest"] : ["v", "type", "code"]);
476
+ if (error.digest !== undefined && (error.code !== "queue_full" || typeof error.digest !== "string" || !DIGEST.test(error.digest))) fail();
477
+ if (error.code === "queue_full") {
478
+ // Older brokers omit correlation; one frame in flight makes its head unambiguous.
479
+ const digest = typeof error.digest === "string" ? error.digest : session.outbound[0]?.digest;
480
+ if (digest) this.backoffFrame(session, digest);
481
+ return;
482
+ }
385
483
  // X2 declares these refusal controls nonterminal. Their unsigned control
386
484
  // cannot settle any application; its local ACK deadline records uncertainty.
387
- if (error.code === "queue_full" || error.code === "replay_refused" || error.code === "unexpected_ack") return;
485
+ if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
388
486
  fail("unavailable");
389
487
  }
390
488
  fail();
@@ -449,6 +547,7 @@ export class RoomLink {
449
547
  else throw error;
450
548
  }
451
549
  const acceptedDigest = await this.frameDigest(bytes);
550
+ session.processingFrame = acceptedDigest;
452
551
  const latest = this.frameCurrent(session, frame);
453
552
  if (latest.current.digest !== digest) refused = "trust_changed";
454
553
  if (result?.status === "stale") {
@@ -471,6 +570,8 @@ export class RoomLink {
471
570
  } else {
472
571
  const peers = presence(result.plaintext, sender.exposedPeers);
473
572
  this.current(session, digest);
573
+ session.pendingPresenceReplies.delete(frame.sender);
574
+ if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
474
575
  try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
475
576
  this.current(session, digest);
476
577
  this.options.store.settleInbox(this.options.name, acceptedDigest);
@@ -486,9 +587,32 @@ export class RoomLink {
486
587
  this.frameCurrent(session, frame);
487
588
  // This ACK only retires ciphertext from the broker queue. A durable refusal
488
589
  // or stale drop is never a signed recipient application-completion receipt.
489
- this.send(session, encodeBrokerControl({ v: 1, type: "received", digest: acceptedDigest }));
590
+ this.retireFrame(session, frame, acceptedDigest);
591
+ session.processingFrame = undefined; session.acknowledgedFrame = undefined;
490
592
  await this.refreshPresenceIfCapacityFree().catch(() => undefined);
491
593
  }
594
+ private retireFrame(session: Session, frame: FrameWire, digest: string): void {
595
+ this.frameCurrent(session, frame);
596
+ // Final admission commits its receipt; deferred admission retains the durable inbox.
597
+ // Waiting for broker storage or deferred admission would pin capacity one indefinitely.
598
+ if (this.options.store.applicationReceipt(this.options.name, digest)
599
+ && !this.options.store.receiptStored(this.options.name, digest)) fail("unavailable");
600
+ this.send(session, encodeBrokerControl({ v: 1, type: "received", digest }));
601
+ session.acknowledgedFrame = digest;
602
+ this.pumpFrames(session);
603
+ }
604
+ private restoreReceipts(session: Session): void {
605
+ this.current(session);
606
+ this.options.store.expireReceipts(this.options.name);
607
+ for (const entry of this.options.store.outbox(this.options.name)) {
608
+ if (entry.kind !== "receipt" || session.outbound.some(frame => frame.digest === entry.digest)) continue;
609
+ if (session.outbound.length >= LIMITS.packets || session.outboundBytes + entry.wire.byteLength > LIMITS.queuedBytes) break;
610
+ const roster = this.current(session).current.digest;
611
+ this.queueFrame(session, entry.wire, entry.digest, false, () => this.publicationCurrent(session, roster), false,
612
+ () => this.options.store.receiptSendable(this.options.name, entry.digest), undefined,
613
+ entry.receiptFor === session.processingFrame ? entry.receiptFor : undefined);
614
+ }
615
+ }
492
616
  private async frameDigest(bytes: Uint8Array): Promise<string> {
493
617
  return encodeBase64url(await sha256(bytes));
494
618
  }
@@ -511,29 +635,86 @@ export class RoomLink {
511
635
  async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
512
636
  const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
513
637
  const session = this.session;
514
- if (!session || session.phase !== "ready") fail("unavailable");
638
+ if (!session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
515
639
  const digest = this.current(session).current.digest;
516
640
  const allowed = () => this.publicationCurrent(session, digest) && current();
517
- return this.serialSend(async () => {
641
+ let queueStarted = false;
642
+ try {
643
+ let presenceWork: Promise<void> | undefined;
644
+ // A chat is the transport barrier for earlier observations. Receipts keep
645
+ // their durable-commit/ACK path and do not wait behind a new observation.
646
+ if (message.type === "chat" && this.republish) {
647
+ clearTimeout(this.republish); this.republish = undefined;
648
+ presenceWork = this.publishPresence();
649
+ // It may fail before reaching the serial lane. Observe rejection now;
650
+ // the reserved chat slot still awaits the original error below.
651
+ void presenceWork.catch(() => undefined);
652
+ }
653
+ // Reserve this chat's FIFO slot before yielding to the earlier observation.
654
+ return await this.serialSend(async () => {
655
+ if (presenceWork) await presenceWork;
518
656
  if (!allowed()) fail("state_changed");
657
+ if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
658
+ || session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
659
+ if (message.type === "receipt") {
660
+ const pending = this.options.store.receiptStored(this.options.name, message.frame);
661
+ if (pending?.state === "dispatching") {
662
+ if (!decodeFrame(pending.wire).recipients.includes(recipient)) fail("untrusted");
663
+ this.restoreReceipts(session);
664
+ return pending.digest;
665
+ }
666
+ }
519
667
  const row = this.current(session, digest);
520
668
  if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
521
- const wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
669
+ let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
522
670
  if (!allowed()) fail("state_changed");
523
- const record = metadata ? await this.options.store.queueApplication(this.options.name, wire, metadata, allowed) : undefined;
524
- const frameDigest = record?.digest ?? await this.frameDigest(wire);
671
+ let frameDigest: string;
672
+ if (metadata) {
673
+ queueStarted = true;
674
+ let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
675
+ try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, allowed); }
676
+ catch (error) {
677
+ // An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
678
+ if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
679
+ && !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
680
+ throw error;
681
+ }
682
+ frameDigest = record.frameDigest ?? record.digest;
683
+ } else if (message.type === "receipt") {
684
+ const ttlMs = this.options.application?.receiptTtlMs?.(this.context(session, digest)) ?? RECEIPT_TTL_MS;
685
+ const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, allowed);
686
+ if (!allowed()) fail("state_changed");
687
+ if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
688
+ frameDigest = receipt.digest; wire = receipt.wire;
689
+ } else frameDigest = await this.frameDigest(wire);
525
690
  if (!allowed()) fail("state_changed");
526
- this.send(session, wire);
691
+ try {
692
+ this.queueFrame(session, wire, frameDigest, false, allowed, false,
693
+ metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
694
+ : message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
695
+ undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
696
+ } catch (error) {
697
+ // The durable outbox owns retry after local backpressure; this is no uncertainty.
698
+ if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
699
+ if (session.outbound[0]?.digest === frameDigest) this.backoffFrame(session, frameDigest);
700
+ }
527
701
  return frameDigest;
528
- });
702
+ }); } catch (error) {
703
+ if (metadata && !queueStarted) throw new RoomNotSentError();
704
+ throw error;
705
+ }
529
706
  }
530
707
  async publishPresence(): Promise<void> {
531
708
  const session = this.session;
532
709
  if (!session || session.phase !== "ready") return;
533
710
  if (this.publishing) { this.publishDirty = true; return; }
711
+ // Keep the already scheduled coalesced snapshot. Cancelling it on every
712
+ // state flip turns an awaited burst back into one durable seal per call.
713
+ if (this.republish && !this.replyPresence && !this.forcePresenceRequest) { this.publishDirty = true; return; }
534
714
  if (this.republish) clearTimeout(this.republish);
535
715
  this.republish = undefined; this.publishDirty = false;
536
- const response = this.replyPresence && !this.forcePresenceRequest;
716
+ const forceRequest = this.forcePresenceRequest;
717
+ const response = this.replyPresence && !forceRequest;
537
718
  if (response) this.replyPresence = false;
538
719
  this.forcePresenceRequest = false;
539
720
  let sent = false;
@@ -549,17 +730,24 @@ export class RoomLink {
549
730
  await this.serialSend(async () => {
550
731
  if (!this.publicationCurrent(session, row.current.digest)) return;
551
732
  const requestReply = !response && session.requestPresenceReply;
552
- const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers, ...(requestReply ? { requestReply: true } : {}) }), sequence: this.options.store.sequence });
733
+ const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
734
+ if (!response && !forceRequest && session.lastPresence === presenceKey) return;
735
+ const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers, ...(requestReply ? { requestReply: true } : {}) }), ...(session.ephemeral ? { ephemeral: true as const } : {}), sequence: this.options.store.sequence });
553
736
  if (!this.publicationCurrent(session, row.current.digest)) return;
554
737
  // Machine-local exposure can change during reserve/encryption/signing even
555
738
  // when the signed roster is unchanged. Never emit an id it now withholds.
739
+ const frameDigest = await this.frameDigest(wire);
740
+ if (!this.publicationCurrent(session, row.current.digest)) return;
556
741
  const allowed = new Set(this.currentPresence(own.exposedPeers).map(peer => peer.id));
557
742
  if (peers.some(peer => !allowed.has(peer.id))) return;
558
- this.send(session, wire); sent = true;
559
- if (requestReply) {
560
- session.requestPresenceReply = false;
561
- if (this.retiredPresenceBytes && this.options.store.ordinaryInboxAvailable(this.options.name, this.retiredPresenceBytes)) this.retiredPresenceBytes = 0;
562
- }
743
+ this.queueFrame(session, wire, frameDigest, requestReply,
744
+ () => this.publicationCurrent(session, row.current.digest) && peers.every(peer => this.currentPresence(own.exposedPeers).some(value => value.id === peer.id)), true, undefined,
745
+ requestReply ? () => {
746
+ if (!session.presenceAsked) { session.pendingPresenceReplies = new Set(recipients); session.presenceAsked = true; }
747
+ session.requestPresenceReply = session.pendingPresenceReplies.size > 0;
748
+ if (this.retiredPresenceBytes && this.options.store.ordinaryInboxAvailable(this.options.name, this.retiredPresenceBytes)) this.retiredPresenceBytes = 0;
749
+ } : undefined);
750
+ session.lastPresence = presenceKey; sent = true;
563
751
  });
564
752
  } catch (error) {
565
753
  this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.