@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,584 @@
1
+ /** Outbound room transport. The local bus and application delivery boundary belong to X4. */
2
+ import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "../broker/protocol.ts";
3
+ import { assertRoomCryptoReady, encodeBase64url, sha256, RoomCryptoError } from "./crypto.ts";
4
+ import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, openFrame, roomMembers, sealFrame, verifyPublicRoster, RoomWireError, type FrameWire, type FrameOpenResult, type VerifiedPublicRoster } from "./wire.ts";
5
+ import type { RoomRefusalReason, RoomState, RoomStore } from "./store.ts";
6
+ import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, type RoomOutgoingInput } from "./store.ts";
7
+ import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.ts";
8
+
9
+ export type RoomLinkState = "offline" | "connecting" | "online";
10
+ export type RoomPresenceState = "idle" | "busy" | "paused" | "offline";
11
+ export interface RoomPresencePeer { id: string; state: RoomPresenceState }
12
+ export interface RoomLinkOptions {
13
+ store: RoomStore;
14
+ name: string;
15
+ presence?: () => readonly { id: string; state: string }[];
16
+ onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[] }) => void;
17
+ onState?: (state: RoomLinkState) => void;
18
+ application?: RoomLinkApplication;
19
+ }
20
+ /** Host-bound application ports; broker/config data never selects callbacks. */
21
+ export interface RoomLinkContext {
22
+ store: RoomStore; name: string; link: RoomLink; current: () => boolean;
23
+ }
24
+ export interface RoomLinkApplication {
25
+ members(context: RoomLinkContext): void;
26
+ receive(context: RoomLinkContext, input: { digest: string; sender: string; message?: RoomMessage }): Promise<void>;
27
+ online(context: RoomLinkContext): Promise<void>;
28
+ stored(context: RoomLinkContext, input: { digest: string; seq: string }): void;
29
+ /** Fixed transport metadata only; never application completion or authority. */
30
+ transportAck?(context: RoomLinkContext, input: { digest: string }): void;
31
+ capacityChanged?(context: RoomLinkContext): void;
32
+ gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
33
+ }
34
+ /** Host port signal: no application claim/effect occurred; the durable plaintext remains pending. */
35
+ export class RoomApplicationDeferredError extends Error {
36
+ constructor(readonly code: "capacity" | "journal_unavailable" | "admission_limit") { super(`Room application deferred: ${code}`); this.name = "RoomApplicationDeferredError"; }
37
+ }
38
+ export class RoomLinkError extends Error {
39
+ constructor(readonly code: "unavailable" | "invalid_wire" | "state_changed" | "untrusted" | "limit") {
40
+ super(`Room link: ${code}`); this.name = "RoomLinkError";
41
+ }
42
+ }
43
+ 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
+ const decoder = new TextDecoder("utf-8", { fatal: true });
45
+ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
46
+ const DIGEST = /^[A-Za-z0-9_-]{43}$/;
47
+ const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
48
+ interface Session {
49
+ ws: WebSocket;
50
+ generation: number;
51
+ room: string;
52
+ broker: string;
53
+ phase: "challenge" | "roster" | "ready";
54
+ queue: Uint8Array[];
55
+ queuedBytes: number;
56
+ processing: boolean;
57
+ challengeDigest?: string | null;
58
+ initialRoster: boolean;
59
+ roster?: VerifiedPublicRoster;
60
+ handshake: ReturnType<typeof setTimeout>;
61
+ requestPresenceReply: boolean;
62
+ ownerUpgrading?: boolean;
63
+ deferredFrame?: Uint8Array;
64
+ }
65
+ function fail(code: ConstructorParameters<typeof RoomLinkError>[0] = "invalid_wire"): never { throw new RoomLinkError(code); }
66
+ function exact(value: unknown, fields: readonly string[]): Record<string, unknown> {
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail();
68
+ const row = value as Record<string, unknown>;
69
+ if (Object.keys(row).length !== fields.length || fields.some(key => !Object.hasOwn(row, key))) fail();
70
+ return row;
71
+ }
72
+ function packet(bytes: Uint8Array): Record<string, unknown> {
73
+ let value: unknown;
74
+ try { value = JSON.parse(decoder.decode(bytes)); } catch { return fail(); }
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail();
76
+ const encoded = canonicalBytes(value);
77
+ if (encoded.length !== bytes.length || encoded.some((byte, index) => byte !== bytes[index])) fail();
78
+ const row = value as Record<string, unknown>;
79
+ if (row.v !== 1 || typeof row.type !== "string") fail();
80
+ return row;
81
+ }
82
+ function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePeer[] {
83
+ const decoded = packet(bytes), row = exact(decoded, Object.hasOwn(decoded, "requestReply") ? ["v", "type", "peers", "requestReply"] : ["v", "type", "peers"]);
84
+ if (row.requestReply !== undefined && typeof row.requestReply !== "boolean") fail();
85
+ if (row.type !== "presence" || !Array.isArray(row.peers) || row.peers.length > 64) fail();
86
+ const peers = row.peers.map(value => {
87
+ const peer = exact(value, ["id", "state"]);
88
+ if (typeof peer.id !== "string" || !PEER_ID.test(peer.id) || !exposed.includes(peer.id) || typeof peer.state !== "string" || !STATES.includes(peer.state)) fail("untrusted");
89
+ return { id: peer.id, state: peer.state as RoomPresenceState };
90
+ });
91
+ if (peers.some((peer, index) => index > 0 && peers[index - 1]!.id >= peer.id)) fail();
92
+ return peers;
93
+ }
94
+
95
+ /** One bounded, generation-fenced outbound connection. No callback receives body text or task data. */
96
+ export class RoomLink {
97
+ private running = false;
98
+ private generation = 0;
99
+ private session?: Session;
100
+ private reconnect?: ReturnType<typeof setTimeout>;
101
+ private attempts = 0;
102
+ private state: RoomLinkState = "offline";
103
+ private publishing = false;
104
+ private publishDirty = false;
105
+ private replyPresence = false;
106
+ private inboxRetry?: Promise<void>;
107
+ private inboxRetryDirty = false;
108
+ private retiredPresenceBytes = 0;
109
+ private forcePresenceRequest = false;
110
+ private republish?: ReturnType<typeof setTimeout>;
111
+ private sendTail: Promise<void> = Promise.resolve();
112
+ constructor(private readonly options: RoomLinkOptions) {}
113
+
114
+ /** Advisory transport state without copying or decoding a room epoch for a status poll. */
115
+ get connectionState(): RoomLinkState { return this.state; }
116
+ status(): { state: RoomLinkState; members: number } {
117
+ try {
118
+ const row = this.options.store.get(this.options.name);
119
+ return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0 };
120
+ } catch { return { state: "offline", members: 0 }; }
121
+ }
122
+ async start(): Promise<void> {
123
+ if (this.running) return;
124
+ this.running = true;
125
+ const generation = ++this.generation;
126
+ try { await assertRoomCryptoReady(); } catch { this.running = false; throw new RoomLinkError("unavailable"); }
127
+ if (!this.running || generation !== this.generation) return;
128
+ this.connect();
129
+ }
130
+ stop(): void {
131
+ this.running = false;
132
+ if (this.reconnect) clearTimeout(this.reconnect);
133
+ this.reconnect = undefined;
134
+ this.disconnect();
135
+ }
136
+ refresh(): void {
137
+ if (this.reconnect) clearTimeout(this.reconnect);
138
+ this.reconnect = undefined;
139
+ this.disconnect();
140
+ if (this.running) this.connect();
141
+ }
142
+ private setState(state: RoomLinkState): void {
143
+ if (this.state === state) return;
144
+ this.state = state;
145
+ try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
146
+ }
147
+ private current(session: Session, digest?: string): RoomState {
148
+ if (!this.running || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
149
+ let row: RoomState | undefined;
150
+ try { row = this.options.store.get(this.options.name); } catch { fail("unavailable"); }
151
+ if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
152
+ return row;
153
+ }
154
+ private isCurrent(session: Session, digest: string): boolean {
155
+ try { this.current(session, digest); return true; } catch { return false; }
156
+ }
157
+ private publicationCurrent(session: Session, digest: string): boolean {
158
+ return session.phase === "ready" && session.roster?.digest === digest && this.isCurrent(session, digest);
159
+ }
160
+ /** A dropped ephemeral observation requests one fresh signed state when its space is available. */
161
+ async refreshPresenceIfCapacityFree(): Promise<void> {
162
+ const session = this.session, bytes = this.retiredPresenceBytes;
163
+ if (!bytes || !session || !this.running) return;
164
+ const row = this.current(session);
165
+ if (!this.publicationCurrent(session, row.current.digest) || !this.options.store.ordinaryInboxAvailable(this.options.name, bytes)) return;
166
+ // Intent survives coalescing, ordinary responses and publication failure.
167
+ // Only an actually sent request with current capacity may clear the numeric flag.
168
+ session.requestPresenceReply = true; this.forcePresenceRequest = true;
169
+ try { await this.publishPresence(); }
170
+ catch (error) { this.retry(session); throw error; }
171
+ }
172
+ private capacityChanged(session: Session, digest: string): void {
173
+ try { this.options.application?.capacityChanged?.(this.context(session, digest)); } catch { /* advisory metadata refresh grants nothing */ }
174
+ }
175
+ private context(session: Session, digest: string): RoomLinkContext {
176
+ return { store: this.options.store, name: this.options.name, link: this, current: () => this.isCurrent(session, digest) };
177
+ }
178
+ private async restorePresence(session: Session): Promise<void> {
179
+ const row = this.current(session), members = roomMembers(row.current);
180
+ for (const entry of this.options.store.pendingInbox(this.options.name)) {
181
+ this.current(session, row.current.digest);
182
+ const member = members.find(value => value.fingerprint === entry.sender);
183
+ if (!member) {
184
+ const application = this.options.store.incomingApplication(this.options.name, entry.digest);
185
+ if (application?.state === "pending" || application?.state === "dispatching") this.options.store.quarantineApplication(this.options.name, entry.digest);
186
+ }
187
+ if (member) {
188
+ let application: RoomMessage | undefined;
189
+ try { application = decodeRoomMessage(entry.plaintext); } catch { /* X3 presence has its own strict codec. */ }
190
+ if (application && this.options.application) {
191
+ await this.deliverApplication(session, row.current.digest, { digest: entry.digest, sender: entry.sender, message: application });
192
+ this.current(session, row.current.digest);
193
+ continue;
194
+ }
195
+ let peers: RoomPresencePeer[] | undefined;
196
+ try { peers = presence(entry.plaintext, member.exposedPeers); } catch { /* a rotation may have removed an exposed id since the durable receive */ }
197
+ if (peers) { try { this.options.onPresence?.({ member: entry.sender, peers }); } catch { /* observation only */ } }
198
+ }
199
+ // Presence is ephemeral: stale exposure/member metadata is discarded, never
200
+ // replayed after a rotation or left at the head of recovery forever.
201
+ this.current(session, row.current.digest);
202
+ this.options.store.settleInbox(this.options.name, entry.digest);
203
+ this.capacityChanged(session, row.current.digest);
204
+ }
205
+ }
206
+ private async deliverApplication(session: Session, digest: string, input: Parameters<RoomLinkApplication["receive"]>[1]): Promise<void> {
207
+ try { await this.options.application?.receive(this.context(session, digest), input); }
208
+ catch (error) { if (!(error instanceof RoomApplicationDeferredError)) throw error; }
209
+ this.current(session, digest);
210
+ }
211
+ /** One bounded pass; repeated external disposition/online notifications coalesce, never poll. */
212
+ retryPendingApplications(): Promise<void> {
213
+ const session = this.session;
214
+ if (!session || session.phase !== "ready" || !this.running) return Promise.resolve();
215
+ if (this.inboxRetry) { this.inboxRetryDirty = true; return this.inboxRetry; }
216
+ const work = Promise.resolve().then(async () => { await this.restorePresence(session); await this.refreshPresenceIfCapacityFree(); });
217
+ this.inboxRetry = work;
218
+ void work.finally(() => {
219
+ if (this.inboxRetry !== work) return;
220
+ this.inboxRetry = undefined;
221
+ const again = this.inboxRetryDirty; this.inboxRetryDirty = false;
222
+ if (again && this.session === session && this.running) void this.retryPendingApplications().catch(() => undefined);
223
+ }).catch(() => undefined);
224
+ return work;
225
+ }
226
+ private disconnect(session = this.session): void {
227
+ if (session && session !== this.session) return;
228
+ this.generation++;
229
+ this.session = undefined;
230
+ this.publishDirty = false; this.replyPresence = false; this.forcePresenceRequest = false; this.inboxRetryDirty = false;
231
+ this.inboxRetry = undefined; // A new ready session must never await an old generation's restore pass.
232
+ if (this.republish) clearTimeout(this.republish);
233
+ this.republish = undefined;
234
+ if (session) {
235
+ clearTimeout(session.handshake);
236
+ session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
237
+ try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
238
+ }
239
+ this.setState("offline");
240
+ }
241
+ private retry(session?: Session): void {
242
+ if (session && this.session !== session) return;
243
+ this.disconnect(session);
244
+ if (!this.running || this.reconnect) return;
245
+ const ceiling = Math.min(LIMITS.reconnectMaxMs, 1000 * 2 ** Math.min(this.attempts++, 9));
246
+ const delay = Math.floor(ceiling / 2 + Math.random() * ceiling / 2);
247
+ this.reconnect = setTimeout(() => { this.reconnect = undefined; if (this.running) this.connect(); }, delay);
248
+ this.reconnect.unref?.();
249
+ }
250
+ private connect(): void {
251
+ if (!this.running || this.session) return;
252
+ let row: RoomState | undefined;
253
+ try { row = this.options.store.get(this.options.name); } catch { this.setState("offline"); return; }
254
+ if (!row || row.left) { this.setState("offline"); return; }
255
+ let url: URL;
256
+ try {
257
+ url = new URL(brokerUrl(row.broker));
258
+ if (url.pathname !== "/" || url.search) fail("untrusted");
259
+ } catch { this.setState("offline"); return; }
260
+ url.pathname = `/v1/rooms/${row.current.room}`;
261
+ let ws: WebSocket;
262
+ try { ws = new WebSocket(url.href); } catch { this.retry(); return; }
263
+ ws.binaryType = "arraybuffer";
264
+ 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,
266
+ handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
267
+ session.handshake.unref?.();
268
+ this.session = session;
269
+ this.setState("connecting");
270
+ ws.addEventListener("message", event => this.receive(session, event.data));
271
+ ws.addEventListener("close", () => this.retry(session));
272
+ ws.addEventListener("error", () => this.retry(session));
273
+ }
274
+ private send(session: Session, bytes: Uint8Array): void {
275
+ this.current(session);
276
+ if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
277
+ session.ws.send(bytes);
278
+ }
279
+ private receive(session: Session, data: unknown): void {
280
+ if (this.session !== session || !this.running) return;
281
+ if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > LIMITS.packetBytes || session.queue.length + (session.deferredFrame ? 1 : 0) >= LIMITS.packets || session.queuedBytes + (session.deferredFrame?.byteLength ?? 0) + data.byteLength > LIMITS.queuedBytes) { this.retry(session); return; }
282
+ const bytes = new Uint8Array(data).slice();
283
+ session.queue.push(bytes); session.queuedBytes += bytes.length;
284
+ this.drain(session);
285
+ }
286
+ private drain(session: Session): void {
287
+ if (session.processing || this.session !== session || !session.queue.length) return;
288
+ const bytes = session.queue.shift()!; session.queuedBytes -= bytes.length;
289
+ session.processing = true;
290
+ void this.handle(session, bytes).catch(() => this.retry(session)).finally(() => {
291
+ session.processing = false;
292
+ this.drain(session);
293
+ });
294
+ }
295
+ private async handle(session: Session, bytes: Uint8Array): Promise<void> {
296
+ const local = this.current(session), message = packet(bytes);
297
+ if (session.phase === "challenge") {
298
+ const challenge = decodeBrokerChallenge(bytes);
299
+ // The broker enforces its own signed challenge deadline. Our local timer
300
+ // bounds this exchange without comparing clocks across hosts (D5).
301
+ session.phase = "roster"; session.challengeDigest = challenge.rosterDigest;
302
+ const owner = decodeRoster(local.wire).owner;
303
+ if (challenge.rosterDigest === null && owner !== this.options.store.identity.public.fingerprint) fail("untrusted");
304
+ const auth = await createBrokerAuth({ challenge, identity: this.options.store.identity, broker: local.broker, room: local.current.room, ...(challenge.rosterDigest === null ? { initialRoster: local.wire } : {}) });
305
+ this.current(session, local.current.digest);
306
+ this.send(session, auth); return;
307
+ }
308
+ if (message.type === "roster") {
309
+ const roster = decodeRoster(bytes), owner = decodeRoster(local.wire).owner;
310
+ const behind = BigInt(roster.epoch) < BigInt(local.current.epoch);
311
+ // An owner may need to publish a locally committed rotation after a disconnect.
312
+ // Verify the old broker roster without ever replacing the local installed pin.
313
+ const proof = await verifyPublicRoster({ wire: bytes, pin: { room: local.current.room, owner, minEpoch: behind ? "0" : local.current.epoch, ...(!behind ? { digest: local.current.digest } : {}) } });
314
+ this.current(session, local.current.digest);
315
+ if (session.initialRoster && proof.digest !== session.challengeDigest && session.challengeDigest !== null) fail("untrusted");
316
+ if (behind) {
317
+ const own = proof.members.find(member => member.fingerprint === this.options.store.identity.public.fingerprint);
318
+ if (owner !== this.options.store.identity.public.fingerprint || !own || own.signingKey !== this.options.store.identity.public.signingKey || own.agreementKey !== this.options.store.identity.public.agreementKey) fail("untrusted");
319
+ } else {
320
+ const installed = await this.options.store.install({ name: this.options.name, broker: local.broker, wire: bytes, expectedDigest: local.current.digest, current: () => this.isCurrent(session, local.current.digest) });
321
+ this.current(session, installed.current.digest);
322
+ this.options.application?.members(this.context(session, installed.current.digest));
323
+ }
324
+ session.initialRoster = false; session.roster = proof;
325
+ session.phase = "roster";
326
+ this.send(session, encodeBrokerControl({ v: 1, type: "roster_ready", digest: proof.digest }));
327
+ return;
328
+ }
329
+ if (message.type === "ready") {
330
+ const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest"]), roster = session.roster;
331
+ 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
+ if (roster.digest !== local.current.digest) {
333
+ if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
334
+ session.ownerUpgrading = true;
335
+ this.send(session, local.wire); return;
336
+ }
337
+ session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
338
+ if (session.deferredFrame) {
339
+ session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
340
+ }
341
+ this.setState("online");
342
+ const context = this.context(session, local.current.digest);
343
+ this.options.application?.members(context);
344
+ await this.retryPendingApplications();
345
+ this.current(session, local.current.digest);
346
+ await this.options.application?.online(context);
347
+ this.current(session, local.current.digest);
348
+ void this.publishPresence().catch(() => this.retry(session));
349
+ return;
350
+ }
351
+ if (message.type === "frame") {
352
+ if (session.phase !== "ready") {
353
+ // X2 may already have one ciphertext in flight while this verified owner
354
+ // installs its newer local roster. Let roster/ready controls pass it;
355
+ // never decrypt, advance replay or ACK until the new roster is ready.
356
+ if (!session.ownerUpgrading || !session.roster || session.roster.owner !== this.options.store.identity.public.fingerprint || BigInt(session.roster.epoch) >= BigInt(local.current.epoch) || session.deferredFrame) fail("untrusted");
357
+ const deferred = decodeFrame(bytes); // Still require a canonical bounded room/recipient frame.
358
+ if (deferred.room !== session.room || !deferred.recipients.includes(this.options.store.identity.public.fingerprint)) fail("untrusted");
359
+ session.deferredFrame = Uint8Array.from(bytes);
360
+ return;
361
+ }
362
+ await this.receiveFrame(session, bytes, local);
363
+ return;
364
+ }
365
+ if (message.type === "stored") {
366
+ const row = exact(message, ["v", "type", "digest", "seq", "duplicate"]);
367
+ 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();
368
+ this.options.application?.stored(this.context(session, local.current.digest), { digest: row.digest, seq: row.seq });
369
+ return; // Broker storage is not a signed recipient application-completion receipt.
370
+ }
371
+ if (message.type === "queue_ack") {
372
+ const row = exact(message, ["v", "type", "digest"]);
373
+ if (typeof row.digest !== "string" || !DIGEST.test(row.digest)) fail();
374
+ try { this.options.application?.transportAck?.(this.context(session, local.current.digest), { digest: row.digest }); }
375
+ catch { /* A transport observer cannot change delivery or reconnect the link. */ }
376
+ return;
377
+ }
378
+ if (message.type === "roster_installed") {
379
+ const row = exact(message, ["v", "type", "epoch", "digest", "changed"]);
380
+ if (row.epoch !== local.current.epoch || row.digest !== local.current.digest || typeof row.changed !== "boolean") fail("untrusted");
381
+ return;
382
+ }
383
+ if (message.type === "error") {
384
+ const error = exact(message, ["v", "type", "code"]);
385
+ // X2 declares these refusal controls nonterminal. Their unsigned control
386
+ // 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;
388
+ fail("unavailable");
389
+ }
390
+ fail();
391
+ }
392
+ private frameCurrent(session: Session, frame: FrameWire): RoomState {
393
+ const row = this.current(session), identity = this.options.store.identity.public;
394
+ const own = roomMembers(row.current).find(member => member.fingerprint === identity.fingerprint);
395
+ if (frame.room !== session.room || !frame.recipients.includes(identity.fingerprint) || !own || own.signingKey !== identity.signingKey || own.agreementKey !== identity.agreementKey) fail("untrusted");
396
+ return row;
397
+ }
398
+ private isFrameCurrent(session: Session, frame: FrameWire): boolean {
399
+ try { this.frameCurrent(session, frame); return true; } catch { return false; }
400
+ }
401
+ private async receiveFrame(session: Session, bytes: Uint8Array, local: RoomState): Promise<void> {
402
+ // Only a canonical frame addressed to this active room membership is eligible
403
+ // for transport retirement. No malformed/foreign packet earns an ACK.
404
+ const frame = decodeFrame(bytes), digest = local.current.digest;
405
+ this.frameCurrent(session, frame);
406
+ let result: FrameOpenResult | undefined, refused: RoomRefusalReason | undefined, invalidPresence = false, invalidApplication = false, unmatchedReceipt = false, freshChatCapacity = false, presenceCapacity = false;
407
+ let application: RoomMessage | undefined;
408
+ try {
409
+ result = await openFrame({ wire: bytes, identity: this.options.store.identity, current: local.current, ...(local.previous ? { previous: local.previous } : {}), replay: { accept: async input => {
410
+ const latest = this.frameCurrent(session, frame);
411
+ if (latest.current.digest !== digest) return { status: "trust_changed" };
412
+ const sender = roomMembers(latest.current).find(member => member.fingerprint === input.sender);
413
+ if (!sender) return { status: "trust_changed" };
414
+ let validPresence = false;
415
+ try {
416
+ if (packet(input.plaintext).type === "presence") { presence(input.plaintext, sender.exposedPeers); validPresence = true; }
417
+ else {
418
+ invalidApplication = true;
419
+ if (!this.options.application) fail("untrusted");
420
+ application = decodeRoomMessage(input.plaintext);
421
+ if (application.type === "chat" && application.envelope.from !== "user" && !sender.exposedPeers.includes(application.envelope.from)) fail("untrusted");
422
+ invalidApplication = false;
423
+ }
424
+ } catch (error) { if (!invalidApplication) invalidPresence = true; throw error; }
425
+ // This readonly metadata test stays outside the grammar catch: SQL/runtime
426
+ // errors cannot become safe drops. Only explicit physical-cap false qualifies.
427
+ if (validPresence && !this.options.store.ordinaryInboxAvailable(this.options.name, input.plaintext.byteLength)) {
428
+ presenceCapacity = true; this.retiredPresenceBytes = Math.max(this.retiredPresenceBytes, input.plaintext.byteLength);
429
+ fail("limit");
430
+ }
431
+ try { return await this.options.store.replay.accept(input); }
432
+ catch (error) {
433
+ if (application?.type === "receipt" && error instanceof RoomUnmatchedReceiptError) unmatchedReceipt = true;
434
+ if (application?.type === "chat" && error instanceof RoomFreshChatCapacityError) freshChatCapacity = true;
435
+ throw error;
436
+ }
437
+ } } });
438
+ } catch (error) {
439
+ if (presenceCapacity) refused = "invalid_presence";
440
+ else if (unmatchedReceipt || freshChatCapacity) refused = "invalid_frame";
441
+ else if (invalidApplication) refused = "invalid_frame";
442
+ else if (invalidPresence) refused = "invalid_presence";
443
+ else if (error instanceof RoomWireError) {
444
+ if (error.code === "wrong_epoch") refused = "wrong_epoch";
445
+ else if (error.code === "state_changed" || error.code === "untrusted") refused = "trust_changed";
446
+ else if (error.code === "authentication_failed" || error.code === "invalid_wire" || error.code === "limit") refused = "invalid_frame";
447
+ else throw error; // A durable-state/runtime failure must retain the broker packet.
448
+ } else if (error instanceof RoomCryptoError && error.code === "authentication_failed") refused = "invalid_frame";
449
+ else throw error;
450
+ }
451
+ const acceptedDigest = await this.frameDigest(bytes);
452
+ const latest = this.frameCurrent(session, frame);
453
+ if (latest.current.digest !== digest) refused = "trust_changed";
454
+ if (result?.status === "stale") {
455
+ // A retained authenticated application receipt may outlive the ordinary
456
+ // crypto inbox tombstone. Unknown lower sequences still fabricate nothing.
457
+ await this.deliverApplication(session, digest, { digest: acceptedDigest, sender: frame.sender });
458
+ refused = "stale";
459
+ }
460
+ if (refused) {
461
+ this.options.store.recordRefused(this.options.name, acceptedDigest, refused, () => this.isFrameCurrent(session, frame));
462
+ this.frameCurrent(session, frame);
463
+ this.options.store.settleInbox(this.options.name, acceptedDigest);
464
+ if (result?.status === "accepted") this.capacityChanged(session, digest);
465
+ } else if (result?.status === "accepted") {
466
+ 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 */ }
467
+ const sender = roomMembers(latest.current).find(member => member.fingerprint === frame.sender);
468
+ if (!sender) fail("untrusted");
469
+ if (application && this.options.application) {
470
+ await this.deliverApplication(session, digest, { digest: acceptedDigest, sender: frame.sender, message: application });
471
+ } else {
472
+ const peers = presence(result.plaintext, sender.exposedPeers);
473
+ this.current(session, digest);
474
+ try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
475
+ this.current(session, digest);
476
+ this.options.store.settleInbox(this.options.name, acceptedDigest);
477
+ this.capacityChanged(session, digest);
478
+ if (packet(result.plaintext).requestReply === true) {
479
+ this.replyPresence = true;
480
+ void this.publishPresence().catch(() => this.retry(session));
481
+ }
482
+ }
483
+ } else if (result?.status === "duplicate") {
484
+ await this.deliverApplication(session, digest, { digest: acceptedDigest, sender: frame.sender });
485
+ } else fail("unavailable");
486
+ this.frameCurrent(session, frame);
487
+ // This ACK only retires ciphertext from the broker queue. A durable refusal
488
+ // or stale drop is never a signed recipient application-completion receipt.
489
+ this.send(session, encodeBrokerControl({ v: 1, type: "received", digest: acceptedDigest }));
490
+ await this.refreshPresenceIfCapacityFree().catch(() => undefined);
491
+ }
492
+ private async frameDigest(bytes: Uint8Array): Promise<string> {
493
+ return encodeBase64url(await sha256(bytes));
494
+ }
495
+ private currentPresence(exposed: readonly string[]): RoomPresencePeer[] {
496
+ const source = this.options.presence?.() ?? [];
497
+ if (!Array.isArray(source) || source.length > 64) fail("limit");
498
+ const peers = source.filter(peer => exposed.includes(peer.id)).map(peer => {
499
+ if (!PEER_ID.test(peer.id) || !STATES.includes(peer.state)) fail();
500
+ return { id: peer.id, state: peer.state as RoomPresenceState };
501
+ }).sort((a, b) => a.id.localeCompare(b.id));
502
+ if (peers.some((peer, index) => index > 0 && peers[index - 1]!.id === peer.id)) fail();
503
+ return peers;
504
+ }
505
+ /** Seal and send in reserve order; a concurrent presence must not overtake an application seq. */
506
+ private serialSend<T>(send: () => Promise<T>): Promise<T> {
507
+ const next = this.sendTail.then(send);
508
+ this.sendTail = next.then(() => undefined, () => undefined);
509
+ return next;
510
+ }
511
+ async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
512
+ const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
513
+ const session = this.session;
514
+ if (!session || session.phase !== "ready") fail("unavailable");
515
+ const digest = this.current(session).current.digest;
516
+ const allowed = () => this.publicationCurrent(session, digest) && current();
517
+ return this.serialSend(async () => {
518
+ if (!allowed()) fail("state_changed");
519
+ const row = this.current(session, digest);
520
+ if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
521
+ const wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
522
+ if (!allowed()) fail("state_changed");
523
+ const record = metadata ? await this.options.store.queueApplication(this.options.name, wire, metadata, allowed) : undefined;
524
+ const frameDigest = record?.digest ?? await this.frameDigest(wire);
525
+ if (!allowed()) fail("state_changed");
526
+ this.send(session, wire);
527
+ return frameDigest;
528
+ });
529
+ }
530
+ async publishPresence(): Promise<void> {
531
+ const session = this.session;
532
+ if (!session || session.phase !== "ready") return;
533
+ if (this.publishing) { this.publishDirty = true; return; }
534
+ if (this.republish) clearTimeout(this.republish);
535
+ this.republish = undefined; this.publishDirty = false;
536
+ const response = this.replyPresence && !this.forcePresenceRequest;
537
+ if (response) this.replyPresence = false;
538
+ this.forcePresenceRequest = false;
539
+ let sent = false;
540
+ this.publishing = true;
541
+ try {
542
+ const row = this.current(session), identity = this.options.store.identity;
543
+ if (!this.publicationCurrent(session, row.current.digest)) return;
544
+ const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
545
+ if (!own) fail("untrusted");
546
+ const peers = this.currentPresence(own.exposedPeers);
547
+ const recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
548
+ if (!recipients.length) return;
549
+ await this.serialSend(async () => {
550
+ if (!this.publicationCurrent(session, row.current.digest)) return;
551
+ 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 });
553
+ if (!this.publicationCurrent(session, row.current.digest)) return;
554
+ // Machine-local exposure can change during reserve/encryption/signing even
555
+ // when the signed roster is unchanged. Never emit an id it now withholds.
556
+ const allowed = new Set(this.currentPresence(own.exposedPeers).map(peer => peer.id));
557
+ 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
+ }
563
+ });
564
+ } catch (error) {
565
+ this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
566
+ throw error;
567
+ } finally {
568
+ this.publishing = false;
569
+ const next = this.session;
570
+ if (sent && response && this.retiredPresenceBytes && session.requestPresenceReply) this.forcePresenceRequest = true;
571
+ const dirty = this.publishDirty || sent && (this.replyPresence || this.forcePresenceRequest);
572
+ this.publishDirty = false;
573
+ if (dirty && this.running && next?.phase === "ready") {
574
+ // Coalesce all changes during one seal into one later fresh snapshot.
575
+ // Capture the target so stop/refresh cannot publish through a stale link.
576
+ this.republish = setTimeout(() => {
577
+ this.republish = undefined;
578
+ if (this.running && this.session === next && next.phase === "ready") void this.publishPresence().catch(() => this.retry(next));
579
+ }, 0);
580
+ this.republish.unref?.();
581
+ }
582
+ }
583
+ }
584
+ }