@aeondave/pi-persona 1.17.3 → 1.17.4

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 (84) hide show
  1. package/AGENTS.md +44 -36
  2. package/CHANGELOG.md +76 -1
  3. package/README.md +23 -13
  4. package/docs/ARCHITECTURE.md +97 -37
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/package.json +12 -6
  10. package/src/agents/agent.ts +6 -0
  11. package/src/bridge.ts +25 -8
  12. package/src/bus/broker/client.ts +119 -21
  13. package/src/bus/broker/host.ts +123 -8
  14. package/src/bus/broker/messages.ts +31 -4
  15. package/src/bus/contact.ts +113 -38
  16. package/src/bus/peers.ts +15 -0
  17. package/src/bus/result-reader.ts +88 -0
  18. package/src/core/brief.ts +14 -4
  19. package/src/core/capabilities.ts +1 -1
  20. package/src/core/leg-thinking.ts +57 -0
  21. package/src/core/ownership.ts +1 -2
  22. package/src/core/pi-compat.ts +5 -12
  23. package/src/core/session-runs.ts +1 -1
  24. package/src/core/types.ts +1 -1
  25. package/src/engine/adapter.ts +83 -19
  26. package/src/engine/async.ts +49 -10
  27. package/src/engine/child.ts +10 -2
  28. package/src/engine/inproc.ts +87 -46
  29. package/src/engine/result-grant.ts +62 -0
  30. package/src/engine/session-tools.ts +6 -0
  31. package/src/engine/stream.ts +6 -0
  32. package/src/engine/worktree.ts +17 -7
  33. package/src/exocom/collaboration-delivery.ts +29 -0
  34. package/src/exocom/collaboration.ts +148 -0
  35. package/src/exocom/envelope.ts +43 -4
  36. package/src/exocom/gate.ts +7 -2
  37. package/src/exocom/install.ts +45 -17
  38. package/src/exocom/ledger.ts +58 -8
  39. package/src/exocom/plane.ts +32 -1
  40. package/src/exocom/profile.ts +40 -0
  41. package/src/exocom/registry.ts +5 -0
  42. package/src/exocom/status.ts +11 -1
  43. package/src/exocom/write-guard.ts +7 -0
  44. package/src/extension/broker-host.ts +61 -9
  45. package/src/extension/engine.ts +20 -21
  46. package/src/extension/hooks.ts +952 -938
  47. package/src/extension/identity.ts +41 -75
  48. package/src/extension/results.ts +136 -0
  49. package/src/extension/shared.ts +17 -0
  50. package/src/extension.ts +2963 -2873
  51. package/src/loader.ts +11 -3
  52. package/src/orchestration/flow-journal.ts +7 -1
  53. package/src/orchestration/flow-run.ts +122 -19
  54. package/src/orchestration/flow.ts +11 -0
  55. package/src/orchestration/roster.ts +71 -23
  56. package/src/orchestration/sdk.ts +44 -6
  57. package/src/orchestration/types.ts +10 -0
  58. package/src/persona/model-participants.ts +20 -7
  59. package/src/persona/orchestrate.ts +2 -0
  60. package/src/persona/persona.ts +1 -1
  61. package/src/results/archive.ts +281 -0
  62. package/src/results/record.ts +166 -0
  63. package/src/results/retrieval.ts +142 -0
  64. package/src/results/types.ts +128 -0
  65. package/src/telemetry/contract.ts +1 -1
  66. package/src/tools/council.ts +40 -23
  67. package/src/tools/delegate-tool.ts +50 -14
  68. package/src/tools/delegate.ts +13 -6
  69. package/src/tools/exocom-collaboration-input.ts +168 -0
  70. package/src/tools/exocom-collaboration.ts +315 -0
  71. package/src/tools/exocom-profile.ts +102 -0
  72. package/src/tools/exocom.ts +20 -3
  73. package/src/tools/flow.ts +24 -8
  74. package/src/tools/intercom-tool.ts +98 -14
  75. package/src/tools/models.ts +15 -0
  76. package/src/tools/monitor.ts +20 -1
  77. package/src/tools/read-result.ts +52 -0
  78. package/src/tools/timer.ts +23 -2
  79. package/src/ui/agent-overlay.ts +11 -3
  80. package/src/ui/agent-tree.ts +43 -1
  81. package/src/ui/literal-tool-result.ts +1 -1
  82. package/src/ui/model-picker.ts +1 -1
  83. package/src/ui/orchestration-launch.ts +49 -0
  84. package/src/ui/session-cards.ts +191 -14
@@ -15,18 +15,21 @@
15
15
  * roster cache, and endless reconnect-after-disconnect "storm" are dropped — YAGNI here,
16
16
  * see spec B6/Task 4 brief). An unexpected disconnect (host died
17
17
  * mid-run) is NOT auto-reconnected: sends silently go nowhere, while in-flight asks/lists
18
- * reject promptly and clean up their timers — the child keeps executing, mute, never crashing.
18
+ * reject promptly and clean up their timers. Authenticated result readers are stricter:
19
+ * one connect attempt, bounded handshake/reads, closed failure replies, never mute success.
19
20
  *
20
21
  * Pure over an injected socket factory (`net`), mirroring `host.ts`'s `net` param — same
21
22
  * transport-injection seam, opposite side of the same fake in tests.
22
23
  */
23
24
 
24
- import { randomUUID } from "node:crypto";
25
+ import { randomUUID, timingSafeEqual } from "node:crypto";
25
26
  import nodeNet from "node:net";
26
27
  import type net from "node:net";
27
28
 
28
29
  import { createFrameReader, encodeFrame } from "./framing.ts";
29
- import { type Frame, isFrame } from "./messages.ts";
30
+ import { type Frame, isFrame, isReaderToken } from "./messages.ts";
31
+ import { isReadRequest, isReadReplyFor, readFailure, RESULT_READ_MAX_PENDING, RESULT_READ_TIMEOUT_MS,
32
+ type ReadReply, type ResultReadRequest } from "../result-reader.ts";
30
33
  import type { MsgKind } from "../inproc.ts";
31
34
 
32
35
  export interface DeliverEvent {
@@ -39,8 +42,10 @@ export interface DeliverEvent {
39
42
  }
40
43
 
41
44
  export interface BrokerClient {
42
- /** Connect (capped backoff) and complete the register handshake. */
45
+ /** Register once. Ordinary comms back off on initial connect; reader grants never retry. */
43
46
  register(): Promise<void>;
47
+ /** Authenticated bounded read. Failures are fixed closed replies; never a mute success. */
48
+ read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
44
49
  /** Fire-and-forget; a dead connection silently drops it (spec: "sends report undelivered"). */
45
50
  send(to: string, kind: MsgKind, text: string): void;
46
51
  /** Blocking request; resolves on a matching `replied`, rejects on `signal` abort or the
@@ -63,6 +68,8 @@ export interface MakeBrokerClientDeps {
63
68
  label?: string;
64
69
  group?: string;
65
70
  peers?: boolean;
71
+ /** Private, one-shot parent grant. Never log this or pass it to a model. */
72
+ readerToken?: string;
66
73
  /** Injected transport (tests use a paired in-memory fake); defaults to real `node:net`. */
67
74
  net?: typeof import("node:net");
68
75
  }
@@ -75,19 +82,17 @@ const ASK_TIMEOUT_MS = 600_000;
75
82
  // let a half-open broker pin `contact_peer list/send` until the child idle watchdog fires.
76
83
  const LIST_TIMEOUT_MS = 10_000;
77
84
 
78
- function delay(ms: number): Promise<void> {
79
- return new Promise((resolve) => {
80
- const t = setTimeout(resolve, ms);
81
- t.unref?.();
82
- });
83
- }
84
-
85
85
  export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
86
86
  const netImpl = deps.net ?? nodeNet;
87
87
 
88
88
  let socket: net.Socket | undefined;
89
89
  let closed = false;
90
90
  let disconnected = false;
91
+ let registerStarted = false;
92
+ let registered = false;
93
+ let cancelConnect: (() => void) | undefined;
94
+ let cancelBackoff: (() => void) | undefined;
95
+ const pendingReads = new Map<string, { request: ResultReadRequest; resolve: (result: ReadReply) => void }>();
91
96
  let registerSettle: { resolve: () => void; reject: (err: Error) => void } | undefined;
92
97
 
93
98
  const deliverListeners = new Set<(evt: DeliverEvent) => void>();
@@ -122,9 +127,21 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
122
127
  function handleFrame(frame: Frame): void {
123
128
  switch (frame.t) {
124
129
  case "registered":
125
- registerSettle?.resolve();
130
+ if (!registerSettle) return;
131
+ if (frame.handle !== deps.handle || (deps.readerToken !== undefined && (!frame.readerToken
132
+ || !timingSafeEqual(Buffer.from(deps.readerToken, "hex"), Buffer.from(frame.readerToken, "hex"))))) {
133
+ failReaderProtocol();
134
+ return;
135
+ }
136
+ registered = true;
137
+ registerSettle.resolve();
126
138
  registerSettle = undefined;
127
139
  return;
140
+ case "result_read_reply": {
141
+ const pending = pendingReads.get(frame.reqId);
142
+ if (pending) pending.resolve(isReadReplyFor(frame.result, pending.request) ? frame.result : readFailure("protocol"));
143
+ return;
144
+ }
128
145
  case "error":
129
146
  // A correlated error belongs to one request on this connection. Do not let an
130
147
  // unknown/stale correlation id affect the register handshake or another ask.
@@ -132,9 +149,8 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
132
149
  pendingAsks.get(frame.msgId)?.reject(new Error(frame.reason));
133
150
  return;
134
151
  }
135
- // A bare error is reserved for the register handshake. Older hosts emit this
136
- // shape, so keep accepting it for protocol compatibility.
137
- registerSettle?.reject(new Error(frame.reason));
152
+ // A bare error belongs to the register handshake, not an outstanding read.
153
+ registerSettle?.reject(new Error(deps.readerToken !== undefined ? "Result reader authentication failed." : frame.reason));
138
154
  registerSettle = undefined;
139
155
  return;
140
156
  case "deliver": {
@@ -167,25 +183,43 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
167
183
  }
168
184
  }
169
185
 
186
+ function failReaderProtocol(): void {
187
+ registerSettle?.reject(new Error("Result reader protocol failure."));
188
+ registerSettle = undefined;
189
+ for (const pending of pendingReads.values()) pending.resolve(readFailure("protocol"));
190
+ socket?.destroy();
191
+ }
192
+
170
193
  function connectOnce(): Promise<net.Socket> {
171
194
  return new Promise((resolve, reject) => {
172
195
  const s = netImpl.connect(deps.endpoint);
173
196
  let settled = false;
197
+ let timer: ReturnType<typeof setTimeout> | undefined;
198
+ const finish = (): void => { clearTimeout(timer); cancelConnect = undefined; };
174
199
  const onConnect = (): void => {
175
200
  if (settled) return;
176
201
  settled = true;
202
+ finish();
177
203
  s.removeListener("error", onError);
178
204
  resolve(s);
179
205
  };
180
206
  const onError = (err: Error): void => {
181
207
  if (settled) return;
182
208
  settled = true;
209
+ finish();
183
210
  s.removeListener("connect", onConnect);
184
211
  s.destroy();
185
212
  reject(err);
186
213
  };
187
214
  s.once("connect", onConnect);
188
215
  s.once("error", onError);
216
+ // Permanent sink also covers cancellation before the eventual connect event.
217
+ s.on("error", () => {});
218
+ cancelConnect = () => onError(new Error("broker client closed"));
219
+ if (deps.readerToken !== undefined) {
220
+ timer = setTimeout(() => onError(new Error("Result reader connection deadline exceeded.")), RESULT_READ_TIMEOUT_MS);
221
+ timer.unref?.();
222
+ }
189
223
  });
190
224
  }
191
225
 
@@ -198,9 +232,21 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
198
232
  try {
199
233
  return await connectOnce();
200
234
  } catch (err) {
235
+ // Local cancellation is terminal. Waiting on an unref'ed retry timer here
236
+ // can leave register() pending after the process has no live handles.
237
+ if (closed) throw new Error("broker client closed");
201
238
  lastErr = err;
202
239
  if (attempt < CONNECT_MAX_ATTEMPTS - 1) {
203
- await delay(Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS));
240
+ await new Promise<void>((resolve) => {
241
+ const finish = (): void => {
242
+ clearTimeout(timer);
243
+ cancelBackoff = undefined;
244
+ resolve();
245
+ };
246
+ const timer = setTimeout(finish, Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS));
247
+ timer.unref?.();
248
+ cancelBackoff = finish;
249
+ });
204
250
  }
205
251
  }
206
252
  }
@@ -208,6 +254,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
208
254
  }
209
255
 
210
256
  function rejectAllPending(reason: string): void {
257
+ for (const p of pendingReads.values()) p.resolve(readFailure("unavailable"));
211
258
  for (const p of pendingAsks.values()) p.reject(new Error(reason));
212
259
  pendingAsks.clear();
213
260
  for (const p of pendingLists.values()) p.reject(new Error(reason));
@@ -215,7 +262,11 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
215
262
  }
216
263
 
217
264
  async function register(): Promise<void> {
218
- const s = await connectWithBackoff();
265
+ if (registerStarted || closed) throw new Error("broker register is one-shot or closed");
266
+ registerStarted = true;
267
+ if (deps.readerToken !== undefined && !isReaderToken(deps.readerToken)) throw new Error("Result reader protocol failure.");
268
+ // Ordinary comms retain initial-connect backoff. A result grant never reconnects/replays.
269
+ const s = await (deps.readerToken !== undefined ? connectOnce() : connectWithBackoff());
219
270
  if (closed) {
220
271
  s.destroy();
221
272
  throw new Error("broker client closed");
@@ -226,16 +277,19 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
226
277
  createFrameReader(
227
278
  (raw) => {
228
279
  if (isFrame(raw)) handleFrame(raw);
280
+ else if (deps.readerToken !== undefined) failReaderProtocol();
229
281
  },
230
- () => s.destroy(),
282
+ () => { if (deps.readerToken !== undefined) failReaderProtocol(); else s.destroy(); },
231
283
  ),
232
284
  );
233
285
  s.on("error", () => {
286
+ if (deps.readerToken !== undefined) s.destroy();
234
287
  /* The close event rejects in-flight requests; this listener only prevents an
235
288
  * unhandled socket error from taking down the child. */
236
289
  });
237
290
  s.once("close", () => {
238
- socket = undefined;
291
+ if (socket === s) socket = undefined;
292
+ registered = false;
239
293
  // A peer-initiated drop (host teardown/crash) between connect and the handshake
240
294
  // reply would otherwise leave register() pending for the child's whole life.
241
295
  registerSettle?.reject(new Error("connection closed during register"));
@@ -248,10 +302,20 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
248
302
  });
249
303
 
250
304
  return new Promise<void>((resolve, reject) => {
251
- registerSettle = { resolve, reject };
305
+ const timer = deps.readerToken !== undefined ? setTimeout(() => {
306
+ registerSettle?.reject(new Error("Result reader handshake deadline exceeded."));
307
+ registerSettle = undefined;
308
+ s.destroy();
309
+ }, RESULT_READ_TIMEOUT_MS) : undefined;
310
+ timer?.unref?.();
311
+ registerSettle = {
312
+ resolve: () => { clearTimeout(timer); resolve(); },
313
+ reject: (error) => { clearTimeout(timer); reject(error); },
314
+ };
252
315
  write({
253
316
  t: "register",
254
317
  handle: deps.handle,
318
+ ...(deps.readerToken !== undefined ? { readerToken: deps.readerToken } : {}),
255
319
  ...(deps.label !== undefined ? { label: deps.label } : {}),
256
320
  ...(deps.group !== undefined ? { group: deps.group } : {}),
257
321
  ...(deps.peers !== undefined ? { peers: deps.peers } : {}),
@@ -259,6 +323,37 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
259
323
  });
260
324
  }
261
325
 
326
+ function read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply> {
327
+ if (signal?.aborted) return Promise.resolve(readFailure("cancelled"));
328
+ if (!isReadRequest(request)) return Promise.resolve(readFailure("invalid"));
329
+ if (deps.readerToken === undefined) return Promise.resolve(readFailure("denied"));
330
+ if (closed || disconnected || !registered || !socket) return Promise.resolve(readFailure("unavailable"));
331
+ if (pendingReads.size >= RESULT_READ_MAX_PENDING) return Promise.resolve(readFailure("busy"));
332
+ const reqId = randomUUID();
333
+ // Snapshot caller data so mutation after dispatch cannot alter correlation checks.
334
+ const snapshot = { ...request };
335
+ return new Promise((resolve) => {
336
+ const finish = (result: ReadReply): void => {
337
+ if (!pendingReads.delete(reqId)) return;
338
+ clearTimeout(timer);
339
+ signal?.removeEventListener("abort", onAbort);
340
+ resolve(result);
341
+ };
342
+ const cancel = (result: ReadReply): void => {
343
+ finish(result);
344
+ write({ t: "result_read_cancel", reqId });
345
+ };
346
+ const onAbort = (): void => cancel(readFailure("cancelled"));
347
+ const timer = setTimeout(() => cancel(readFailure("timeout")), RESULT_READ_TIMEOUT_MS);
348
+ timer.unref?.();
349
+ pendingReads.set(reqId, { request: snapshot, resolve: finish });
350
+ signal?.addEventListener("abort", onAbort, { once: true });
351
+ try {
352
+ socket!.write(encodeFrame({ t: "result_read", reqId, ...snapshot }));
353
+ } catch { finish(readFailure("unavailable")); socket?.destroy(); }
354
+ });
355
+ }
356
+
262
357
  function send(to: string, kind: MsgKind, text: string): void {
263
358
  write({ t: "send", to, kind, text, msgId: randomUUID(), expectsReply: false });
264
359
  }
@@ -357,6 +452,9 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
357
452
  function close(): void {
358
453
  if (closed) return;
359
454
  closed = true;
455
+ registered = false;
456
+ cancelConnect?.();
457
+ cancelBackoff?.();
360
458
  const s = socket;
361
459
  if (s) {
362
460
  write({ t: "bye" });
@@ -375,5 +473,5 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
375
473
  rejectAllPending("broker client closed");
376
474
  }
377
475
 
378
- return { register, send, ask, reply, list, onDeliver, onSteer, onClose, close };
476
+ return { register, read, send, ask, reply, list, onDeliver, onSteer, onClose, close };
379
477
  }
@@ -29,17 +29,23 @@
29
29
  */
30
30
 
31
31
  import { existsSync, unlinkSync } from "node:fs";
32
+ import { randomBytes, timingSafeEqual } from "node:crypto";
32
33
  import nodeNet from "node:net";
33
34
  import type net from "node:net";
34
35
 
35
36
  import { createFrameReader, encodeFrame } from "./framing.ts";
36
37
  import { type Frame, isFrame } from "./messages.ts";
37
38
  import type { InProcessBus } from "../inproc.ts";
39
+ import { isReadReplyFor, readFailure, RESULT_READ_MAX_FRAME_BYTES, RESULT_READ_MAX_PENDING,
40
+ RESULT_READ_MAX_REQUESTS, RESULT_READ_TIMEOUT_MS, type PreparedResultReader, type ReadReply,
41
+ type ScopedReader } from "../result-reader.ts";
38
42
 
39
43
  export interface BrokerHost {
40
44
  endpoint: string;
41
45
  close(): Promise<void>;
42
46
  connectedHandles(): string[];
47
+ /** Parent-only preflight. Reserve before bus/adapter registration or spawning. */
48
+ prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader>;
43
49
  /** Pushes a `steer` frame directly to a connected handle (spec B4) — the supervisor's own
44
50
  * live voice, NOT a bus envelope (it bypasses `bus.send`/the outbound drain entirely, so
45
51
  * it can't be mistaken for a peer/child message on the wire). Returns false (a harmless
@@ -158,6 +164,39 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
158
164
  const sockets = new Set<net.Socket>();
159
165
  const expected = new Set<string>();
160
166
  const pendingSteer = new Map<string, string[]>();
167
+ type Grant = { token: Buffer; reader: ScopedReader; active: boolean; bound: boolean; stop?: () => void; revoke: () => void };
168
+ const grants = new Map<string, Grant>();
169
+ let closed = false;
170
+
171
+ function revokeReader(reader: ScopedReader): void {
172
+ try { reader.revoke(); } catch { /* cleanup cannot leak callback errors or strand siblings */ }
173
+ }
174
+
175
+ async function prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader> {
176
+ if (closed || signal?.aborted || !handle || handle.length > 256 || handle === deps.supervisorHandle
177
+ || connections.has(handle) || grants.has(handle) || expected.has(handle) || bus.participants().includes(handle)) {
178
+ revokeReader(reader);
179
+ throw new Error("Result reader preparation refused.");
180
+ }
181
+ const token = randomBytes(32);
182
+ const grant: Grant = { token, reader, active: true, bound: false, revoke: () => {
183
+ if (!grant.active) return;
184
+ grant.active = false;
185
+ signal?.removeEventListener("abort", grant.revoke);
186
+ if (grants.get(handle) === grant) {
187
+ grants.delete(handle);
188
+ expected.delete(handle);
189
+ pendingSteer.delete(handle);
190
+ }
191
+ token.fill(0);
192
+ grant.stop?.();
193
+ revokeReader(reader);
194
+ } };
195
+ grants.set(handle, grant);
196
+ expected.add(handle);
197
+ signal?.addEventListener("abort", grant.revoke, { once: true });
198
+ return { token: token.toString("hex"), revoke: grant.revoke, wasBound: () => grant.bound };
199
+ }
161
200
 
162
201
  function flushPendingSteer(handle: string): void {
163
202
  const conn = connections.get(handle);
@@ -207,10 +246,19 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
207
246
  let handle: string | undefined;
208
247
  let group: string | undefined;
209
248
  const pendingAsks = new Map<string, AbortController>();
249
+ let ended = false;
250
+ let boundGrant: Grant | undefined;
251
+ const seenReads = new Set<string>();
252
+ const pendingReads = new Map<string, { ac: AbortController; finish: (result?: ReadReply) => void }>();
210
253
 
211
254
  const write = (frame: Frame): void => {
212
255
  try {
213
- socket.write(encodeFrame(frame));
256
+ const encoded = encodeFrame(frame);
257
+ if (frame.t === "result_read_reply" && encoded.length > RESULT_READ_MAX_FRAME_BYTES) {
258
+ socket.write(encodeFrame({ t: "result_read_reply", reqId: frame.reqId, result: readFailure("protocol") }));
259
+ return;
260
+ }
261
+ socket.write(encoded);
214
262
  } catch {
215
263
  /* peer went away mid-write */
216
264
  }
@@ -220,6 +268,11 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
220
268
  const conn = { write };
221
269
 
222
270
  const cleanup = (): void => {
271
+ if (ended) return;
272
+ ended = true;
273
+ for (const pending of pendingReads.values()) { pending.finish(); pending.ac.abort(); }
274
+ seenReads.clear();
275
+ boundGrant?.revoke();
223
276
  for (const ac of pendingAsks.values()) ac.abort();
224
277
  pendingAsks.clear();
225
278
  // `bye` releases the handle while `handle` stays set (the socket's own `close`
@@ -233,10 +286,55 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
233
286
  const errorReason = (error: unknown): string => error instanceof Error ? error.message : String(error);
234
287
 
235
288
  const dispatch = (frame: Frame): void => {
289
+ if (ended) return;
236
290
  switch (frame.t) {
291
+ case "result_read": {
292
+ const grant = boundGrant;
293
+ const respond = (result: ReadReply): void => write({ t: "result_read_reply", reqId: frame.reqId, result });
294
+ if (!handle || connections.get(handle) !== conn || !grant?.active) { respond(readFailure("denied")); return; }
295
+ if (seenReads.has(frame.reqId)) {
296
+ const pending = pendingReads.get(frame.reqId);
297
+ pending?.finish();
298
+ pending?.ac.abort();
299
+ respond(readFailure("protocol"));
300
+ return;
301
+ }
302
+ if (seenReads.size >= RESULT_READ_MAX_REQUESTS) { grant.revoke(); return; }
303
+ seenReads.add(frame.reqId);
304
+ if (pendingReads.size >= RESULT_READ_MAX_PENDING) { respond(readFailure("busy")); return; }
305
+ const { t: _t, reqId, ...request } = frame;
306
+ const ac = new AbortController();
307
+ const finish = (result?: ReadReply): void => {
308
+ if (pendingReads.get(reqId)?.ac !== ac) return;
309
+ pendingReads.delete(reqId);
310
+ clearTimeout(timer);
311
+ if (result && !ended && grant.active && handle && connections.get(handle) === conn) respond(result);
312
+ };
313
+ const timer = setTimeout(() => { finish(readFailure("timeout")); ac.abort(); }, RESULT_READ_TIMEOUT_MS);
314
+ timer.unref?.();
315
+ pendingReads.set(reqId, { ac, finish });
316
+ void Promise.resolve().then(() => {
317
+ if (ac.signal.aborted || !grant.active) return readFailure("revoked");
318
+ return grant.reader.read(request, ac.signal);
319
+ }).then((result) => {
320
+ try { finish(isReadReplyFor(result, request) ? result : readFailure("protocol")); }
321
+ catch { finish(readFailure("protocol")); }
322
+ }, () => finish(readFailure("unavailable")));
323
+ return;
324
+ }
325
+ case "result_read_cancel": {
326
+ const pending = pendingReads.get(frame.reqId);
327
+ pending?.finish(readFailure("cancelled"));
328
+ pending?.ac.abort();
329
+ return;
330
+ }
237
331
  case "register": {
332
+ const reject = (reason: string): void => {
333
+ write({ t: "error", reason });
334
+ if (frame.readerToken !== undefined || grants.has(frame.handle)) socket.destroy();
335
+ };
238
336
  if (frame.handle === deps.supervisorHandle) {
239
- write({ t: "error", reason: "reserved handle" });
337
+ reject("reserved handle");
240
338
  return;
241
339
  }
242
340
  // Registration is one-shot per socket and exclusive per handle: a
@@ -244,19 +342,31 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
244
342
  // would then unregister a handle it no longer owns (or leak the one it
245
343
  // silently abandoned).
246
344
  if (handle) {
247
- write({ t: "error", reason: "already registered" });
345
+ reject("already registered");
248
346
  return;
249
347
  }
250
348
  if (connections.has(frame.handle)) {
251
- write({ t: "error", reason: `handle already connected: ${frame.handle}` });
349
+ reject("handle already connected");
252
350
  return;
253
351
  }
352
+ const grant = grants.get(frame.handle);
353
+ // A supplied unknown/expired token never falls through to ordinary registration.
354
+ if (grant || frame.readerToken !== undefined) {
355
+ if (!grant?.active || grant.bound || !frame.readerToken
356
+ || !timingSafeEqual(grant.token, Buffer.from(frame.readerToken, "hex"))) {
357
+ reject("result reader authentication failed");
358
+ return;
359
+ }
360
+ grant.bound = true;
361
+ boundGrant = grant;
362
+ grant.stop = () => { if (!ended) { cleanup(); socket.destroy(); } };
363
+ }
254
364
  handle = frame.handle;
255
365
  group = frame.group;
256
366
  bus.register(handle);
257
367
  connections.set(handle, conn);
258
368
  if (frame.peers) peerRegistry.set(handle, { handle, label: frame.label ?? handle, group: frame.group ?? "" });
259
- write({ t: "registered", handle });
369
+ write({ t: "registered", handle, ...(frame.readerToken ? { readerToken: frame.readerToken } : {}) });
260
370
  drain(handle);
261
371
  flushPendingSteer(handle);
262
372
  return;
@@ -324,6 +434,8 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
324
434
  const handleFrame = (raw: unknown): void => {
325
435
  if (!isFrame(raw)) {
326
436
  write({ t: "error", reason: "invalid frame" });
437
+ if (boundGrant || (typeof raw === "object" && raw !== null
438
+ && ("readerToken" in raw || ("t" in raw && String(raw.t).startsWith("result_read"))))) socket.destroy();
327
439
  return;
328
440
  }
329
441
  try {
@@ -337,8 +449,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
337
449
  }
338
450
  };
339
451
 
340
- socket.on("data", createFrameReader(handleFrame, (e) => {
341
- write({ t: "error", reason: e.message });
452
+ socket.on("data", createFrameReader(handleFrame, () => {
453
+ // JSON parser diagnostics can contain the private registration token.
454
+ write({ t: "error", reason: "invalid frame" });
342
455
  socket.destroy();
343
456
  }));
344
457
  socket.on("error", () => socket.destroy());
@@ -350,9 +463,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
350
463
 
351
464
  const server = await bindServer(netImpl, deps.endpoint, handleConnection);
352
465
 
353
- let closed = false;
354
466
  return {
355
467
  endpoint: deps.endpoint,
468
+ prepareResultReader,
356
469
  connectedHandles: () => [...connections.keys()],
357
470
  steer(handle: string, text: string): boolean {
358
471
  const conn = connections.get(handle);
@@ -370,12 +483,14 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
370
483
  expected.add(handle);
371
484
  },
372
485
  forget(handle: string): void {
486
+ grants.get(handle)?.revoke();
373
487
  expected.delete(handle);
374
488
  pendingSteer.delete(handle);
375
489
  },
376
490
  async close(): Promise<void> {
377
491
  if (closed) return;
378
492
  closed = true;
493
+ for (const grant of grants.values()) grant.revoke();
379
494
  unsubBus();
380
495
  expected.clear();
381
496
  pendingSteer.clear();
@@ -5,14 +5,28 @@
5
5
  * `messages.ts` validation STYLE (structural per-field checks, one explicit assert per
6
6
  * case) but drops presence/sessions/attachments/broadcasts entirely (YAGNI here).
7
7
  *
8
+ * Authenticated result_read/result_read_reply/result_read_cancel frames extend the same
9
+ * connection with a closed bounded page contract. No archive summaries or client grants.
8
10
  * Pure module: no Pi imports, unit-tested.
9
11
  */
10
12
 
11
13
  import type { MsgKind } from "../inproc.ts";
14
+ import { closedRecord, isReadReply, isReadRequest, type ReadReply, type ResultReadRequest } from "../result-reader.ts";
15
+
16
+ export function isReaderToken(value: unknown): value is string {
17
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
18
+ }
19
+
20
+ function isRequestId(value: unknown): value is string {
21
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
22
+ }
12
23
 
13
24
  export type Frame =
14
- | { t: "register"; handle: string; label?: string; group?: string; peers?: boolean }
15
- | { t: "registered"; handle: string }
25
+ | { t: "register"; handle: string; label?: string; group?: string; peers?: boolean; readerToken?: string }
26
+ | { t: "registered"; handle: string; readerToken?: string }
27
+ | ({ t: "result_read"; reqId: string } & ResultReadRequest)
28
+ | { t: "result_read_reply"; reqId: string; result: ReadReply }
29
+ | { t: "result_read_cancel"; reqId: string }
16
30
  | { t: "send"; to: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
17
31
  | { t: "cancel"; msgId: string }
18
32
  | { t: "deliver"; from: string; fromLabel?: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
@@ -39,14 +53,27 @@ function isPeerEntry(value: unknown): value is { handle: string; label: string }
39
53
  export function isFrame(value: unknown): value is Frame {
40
54
  if (!isRecord(value)) return false;
41
55
  switch (value.t) {
56
+ case "result_read": {
57
+ if (!closedRecord(value, ["t", "reqId", "ref", "offset", "limit"]) || !isRequestId(value.reqId)) return false;
58
+ const { t: _t, reqId: _id, ...request } = value;
59
+ return isReadRequest(request);
60
+ }
61
+ case "result_read_reply":
62
+ return closedRecord(value, ["t", "reqId", "result"]) && isRequestId(value.reqId) && isReadReply(value.result);
63
+ case "result_read_cancel":
64
+ return closedRecord(value, ["t", "reqId"]) && isRequestId(value.reqId);
42
65
  case "register": {
43
- if (typeof value.handle !== "string") return false;
66
+ if (!closedRecord(value, ["t", "handle", "label", "group", "peers", "readerToken"])) return false;
67
+ if ("readerToken" in value && !isReaderToken(value.readerToken)) return false;
68
+ if (typeof value.handle !== "string" || value.handle.length === 0 || value.handle.length > 256) return false;
44
69
  if (value.label !== undefined && typeof value.label !== "string") return false;
45
70
  if (value.group !== undefined && typeof value.group !== "string") return false;
46
71
  return value.peers === undefined || typeof value.peers === "boolean";
47
72
  }
48
73
  case "registered":
49
- return typeof value.handle === "string";
74
+ return closedRecord(value, ["t", "handle", "readerToken"]) && typeof value.handle === "string"
75
+ && value.handle.length > 0 && value.handle.length <= 256
76
+ && (!("readerToken" in value) || isReaderToken(value.readerToken));
50
77
  case "send":
51
78
  return (
52
79
  typeof value.to === "string" &&