@aeondave/pi-persona 1.17.3 → 1.17.5

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 (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  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/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
@@ -2,7 +2,7 @@
2
2
  * Broker client — the child-side half of the relay (spec B3/B6): connects to the
3
3
  * session-scoped endpoint (`paths.ts`), performs the `register`/`registered` handshake,
4
4
  * and exposes the same shape a local `contact_supervisor`/`contact_peer` binding needs —
5
- * `send` (fire-and-forget), `ask` (a blocking `send{expectsReply}` correlated to a
5
+ * `send` (one-way with a bounded host-admission receipt), `ask` (a blocking `send{expectsReply}` correlated to a
6
6
  * `replied{askId}`, 10-minute cap, mirrors `bus.ask`, and sends `cancel{msgId}` when
7
7
  * aborted), `reply` (answers a host-issued
8
8
  * blocking `deliver`), `list` (the engine-scoped peer roster, B7), and `onDeliver`/`onSteer`
@@ -14,19 +14,22 @@
14
14
  * exponential backoff (250ms–5s) on the INITIAL connect only (comtac's presence tracking,
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
- * 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.
17
+ * mid-run) is NOT auto-reconnected: sends resolve false, while in-flight asks/lists
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,10 +42,13 @@ 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>;
44
- /** Fire-and-forget; a dead connection silently drops it (spec: "sends report undelivered"). */
45
- send(to: string, kind: MsgKind, text: string): void;
47
+ /** Authenticated bounded read. Failures are fixed closed replies; never a mute success. */
48
+ read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
49
+ /** One-way message; true only after host bus admission, never a recipient answer.
50
+ * Missing receipt, cancellation, saturation or disconnect resolve false without replay. */
51
+ send(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<boolean>;
46
52
  /** Blocking request; resolves on a matching `replied`, rejects on `signal` abort or the
47
53
  * 10-minute cap (mirrors `bus.ask`'s default). */
48
54
  ask(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<string>;
@@ -63,6 +69,8 @@ export interface MakeBrokerClientDeps {
63
69
  label?: string;
64
70
  group?: string;
65
71
  peers?: boolean;
72
+ /** Private, one-shot parent grant. Never log this or pass it to a model. */
73
+ readerToken?: string;
66
74
  /** Injected transport (tests use a paired in-memory fake); defaults to real `node:net`. */
67
75
  net?: typeof import("node:net");
68
76
  }
@@ -74,13 +82,8 @@ const ASK_TIMEOUT_MS = 600_000;
74
82
  // A roster lookup is one local socket/pipe round-trip, unlike a blocking supervisor ask. Do not
75
83
  // let a half-open broker pin `contact_peer list/send` until the child idle watchdog fires.
76
84
  const LIST_TIMEOUT_MS = 10_000;
77
-
78
- function delay(ms: number): Promise<void> {
79
- return new Promise((resolve) => {
80
- const t = setTimeout(resolve, ms);
81
- t.unref?.();
82
- });
83
- }
85
+ export const SEND_TIMEOUT_MS = 10_000;
86
+ export const MAX_PENDING_SENDS = 64;
84
87
 
85
88
  export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
86
89
  const netImpl = deps.net ?? nodeNet;
@@ -88,6 +91,12 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
88
91
  let socket: net.Socket | undefined;
89
92
  let closed = false;
90
93
  let disconnected = false;
94
+ let registerStarted = false;
95
+ let registered = false;
96
+ let cancelConnect: (() => void) | undefined;
97
+ let cancelBackoff: (() => void) | undefined;
98
+ const pendingReads = new Map<string, { request: ResultReadRequest; resolve: (result: ReadReply) => void }>();
99
+ const pendingSends = new Map<string, (delivered: boolean) => void>();
91
100
  let registerSettle: { resolve: () => void; reject: (err: Error) => void } | undefined;
92
101
 
93
102
  const deliverListeners = new Set<(evt: DeliverEvent) => void>();
@@ -96,9 +105,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
96
105
  const pendingAsks = new Map<string, { resolve: (text: string) => void; reject: (err: Error) => void }>();
97
106
  const pendingLists = new Map<string, { resolve: (peers: Array<{ handle: string; label: string }>) => void; reject: (err: Error) => void }>();
98
107
 
99
- /** Best-effort write: a frame sent while disconnected is silently dropped — matches
100
- * the design's "sends report undelivered" (no throw the caller could even act on,
101
- * `send`/`reply` are `void`). */
108
+ /** Best-effort control frame write. Confirmed sends use their own guarded write and receipt. */
102
109
  function write(frame: Frame): void {
103
110
  if (!socket) return;
104
111
  try {
@@ -121,20 +128,35 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
121
128
 
122
129
  function handleFrame(frame: Frame): void {
123
130
  switch (frame.t) {
131
+ case "sent":
132
+ pendingSends.get(frame.msgId)?.(frame.delivered);
133
+ return;
124
134
  case "registered":
125
- registerSettle?.resolve();
135
+ if (!registerSettle) return;
136
+ if (frame.handle !== deps.handle || (deps.readerToken !== undefined && (!frame.readerToken
137
+ || !timingSafeEqual(Buffer.from(deps.readerToken, "hex"), Buffer.from(frame.readerToken, "hex"))))) {
138
+ failReaderProtocol();
139
+ return;
140
+ }
141
+ registered = true;
142
+ registerSettle.resolve();
126
143
  registerSettle = undefined;
127
144
  return;
145
+ case "result_read_reply": {
146
+ const pending = pendingReads.get(frame.reqId);
147
+ if (pending) pending.resolve(isReadReplyFor(frame.result, pending.request) ? frame.result : readFailure("protocol"));
148
+ return;
149
+ }
128
150
  case "error":
129
151
  // A correlated error belongs to one request on this connection. Do not let an
130
152
  // unknown/stale correlation id affect the register handshake or another ask.
131
153
  if (frame.msgId !== undefined) {
154
+ pendingSends.get(frame.msgId)?.(false);
132
155
  pendingAsks.get(frame.msgId)?.reject(new Error(frame.reason));
133
156
  return;
134
157
  }
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));
158
+ // A bare error belongs to the register handshake, not an outstanding read.
159
+ registerSettle?.reject(new Error(deps.readerToken !== undefined ? "Result reader authentication failed." : frame.reason));
138
160
  registerSettle = undefined;
139
161
  return;
140
162
  case "deliver": {
@@ -167,25 +189,43 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
167
189
  }
168
190
  }
169
191
 
192
+ function failReaderProtocol(): void {
193
+ registerSettle?.reject(new Error("Result reader protocol failure."));
194
+ registerSettle = undefined;
195
+ for (const pending of pendingReads.values()) pending.resolve(readFailure("protocol"));
196
+ socket?.destroy();
197
+ }
198
+
170
199
  function connectOnce(): Promise<net.Socket> {
171
200
  return new Promise((resolve, reject) => {
172
201
  const s = netImpl.connect(deps.endpoint);
173
202
  let settled = false;
203
+ let timer: ReturnType<typeof setTimeout> | undefined;
204
+ const finish = (): void => { clearTimeout(timer); cancelConnect = undefined; };
174
205
  const onConnect = (): void => {
175
206
  if (settled) return;
176
207
  settled = true;
208
+ finish();
177
209
  s.removeListener("error", onError);
178
210
  resolve(s);
179
211
  };
180
212
  const onError = (err: Error): void => {
181
213
  if (settled) return;
182
214
  settled = true;
215
+ finish();
183
216
  s.removeListener("connect", onConnect);
184
217
  s.destroy();
185
218
  reject(err);
186
219
  };
187
220
  s.once("connect", onConnect);
188
221
  s.once("error", onError);
222
+ // Permanent sink also covers cancellation before the eventual connect event.
223
+ s.on("error", () => {});
224
+ cancelConnect = () => onError(new Error("broker client closed"));
225
+ if (deps.readerToken !== undefined) {
226
+ timer = setTimeout(() => onError(new Error("Result reader connection deadline exceeded.")), RESULT_READ_TIMEOUT_MS);
227
+ timer.unref?.();
228
+ }
189
229
  });
190
230
  }
191
231
 
@@ -198,9 +238,21 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
198
238
  try {
199
239
  return await connectOnce();
200
240
  } catch (err) {
241
+ // Local cancellation is terminal. Waiting on an unref'ed retry timer here
242
+ // can leave register() pending after the process has no live handles.
243
+ if (closed) throw new Error("broker client closed");
201
244
  lastErr = err;
202
245
  if (attempt < CONNECT_MAX_ATTEMPTS - 1) {
203
- await delay(Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS));
246
+ await new Promise<void>((resolve) => {
247
+ const finish = (): void => {
248
+ clearTimeout(timer);
249
+ cancelBackoff = undefined;
250
+ resolve();
251
+ };
252
+ const timer = setTimeout(finish, Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS));
253
+ timer.unref?.();
254
+ cancelBackoff = finish;
255
+ });
204
256
  }
205
257
  }
206
258
  }
@@ -208,6 +260,8 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
208
260
  }
209
261
 
210
262
  function rejectAllPending(reason: string): void {
263
+ for (const settle of pendingSends.values()) settle(false);
264
+ for (const p of pendingReads.values()) p.resolve(readFailure("unavailable"));
211
265
  for (const p of pendingAsks.values()) p.reject(new Error(reason));
212
266
  pendingAsks.clear();
213
267
  for (const p of pendingLists.values()) p.reject(new Error(reason));
@@ -215,7 +269,11 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
215
269
  }
216
270
 
217
271
  async function register(): Promise<void> {
218
- const s = await connectWithBackoff();
272
+ if (registerStarted || closed) throw new Error("broker register is one-shot or closed");
273
+ registerStarted = true;
274
+ if (deps.readerToken !== undefined && !isReaderToken(deps.readerToken)) throw new Error("Result reader protocol failure.");
275
+ // Ordinary comms retain initial-connect backoff. A result grant never reconnects/replays.
276
+ const s = await (deps.readerToken !== undefined ? connectOnce() : connectWithBackoff());
219
277
  if (closed) {
220
278
  s.destroy();
221
279
  throw new Error("broker client closed");
@@ -226,16 +284,19 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
226
284
  createFrameReader(
227
285
  (raw) => {
228
286
  if (isFrame(raw)) handleFrame(raw);
287
+ else if (deps.readerToken !== undefined) failReaderProtocol();
229
288
  },
230
- () => s.destroy(),
289
+ () => { if (deps.readerToken !== undefined) failReaderProtocol(); else s.destroy(); },
231
290
  ),
232
291
  );
233
292
  s.on("error", () => {
293
+ if (deps.readerToken !== undefined) s.destroy();
234
294
  /* The close event rejects in-flight requests; this listener only prevents an
235
295
  * unhandled socket error from taking down the child. */
236
296
  });
237
297
  s.once("close", () => {
238
- socket = undefined;
298
+ if (socket === s) socket = undefined;
299
+ registered = false;
239
300
  // A peer-initiated drop (host teardown/crash) between connect and the handshake
240
301
  // reply would otherwise leave register() pending for the child's whole life.
241
302
  registerSettle?.reject(new Error("connection closed during register"));
@@ -248,10 +309,20 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
248
309
  });
249
310
 
250
311
  return new Promise<void>((resolve, reject) => {
251
- registerSettle = { resolve, reject };
312
+ const timer = deps.readerToken !== undefined ? setTimeout(() => {
313
+ registerSettle?.reject(new Error("Result reader handshake deadline exceeded."));
314
+ registerSettle = undefined;
315
+ s.destroy();
316
+ }, RESULT_READ_TIMEOUT_MS) : undefined;
317
+ timer?.unref?.();
318
+ registerSettle = {
319
+ resolve: () => { clearTimeout(timer); resolve(); },
320
+ reject: (error) => { clearTimeout(timer); reject(error); },
321
+ };
252
322
  write({
253
323
  t: "register",
254
324
  handle: deps.handle,
325
+ ...(deps.readerToken !== undefined ? { readerToken: deps.readerToken } : {}),
255
326
  ...(deps.label !== undefined ? { label: deps.label } : {}),
256
327
  ...(deps.group !== undefined ? { group: deps.group } : {}),
257
328
  ...(deps.peers !== undefined ? { peers: deps.peers } : {}),
@@ -259,8 +330,55 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
259
330
  });
260
331
  }
261
332
 
262
- function send(to: string, kind: MsgKind, text: string): void {
263
- write({ t: "send", to, kind, text, msgId: randomUUID(), expectsReply: false });
333
+ function read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply> {
334
+ if (signal?.aborted) return Promise.resolve(readFailure("cancelled"));
335
+ if (!isReadRequest(request)) return Promise.resolve(readFailure("invalid"));
336
+ if (deps.readerToken === undefined) return Promise.resolve(readFailure("denied"));
337
+ if (closed || disconnected || !registered || !socket) return Promise.resolve(readFailure("unavailable"));
338
+ if (pendingReads.size >= RESULT_READ_MAX_PENDING) return Promise.resolve(readFailure("busy"));
339
+ const reqId = randomUUID();
340
+ // Snapshot caller data so mutation after dispatch cannot alter correlation checks.
341
+ const snapshot = { ...request };
342
+ return new Promise((resolve) => {
343
+ const finish = (result: ReadReply): void => {
344
+ if (!pendingReads.delete(reqId)) return;
345
+ clearTimeout(timer);
346
+ signal?.removeEventListener("abort", onAbort);
347
+ resolve(result);
348
+ };
349
+ const cancel = (result: ReadReply): void => {
350
+ finish(result);
351
+ write({ t: "result_read_cancel", reqId });
352
+ };
353
+ const onAbort = (): void => cancel(readFailure("cancelled"));
354
+ const timer = setTimeout(() => cancel(readFailure("timeout")), RESULT_READ_TIMEOUT_MS);
355
+ timer.unref?.();
356
+ pendingReads.set(reqId, { request: snapshot, resolve: finish });
357
+ signal?.addEventListener("abort", onAbort, { once: true });
358
+ try {
359
+ socket!.write(encodeFrame({ t: "result_read", reqId, ...snapshot }));
360
+ } catch { finish(readFailure("unavailable")); socket?.destroy(); }
361
+ });
362
+ }
363
+
364
+ function send(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<boolean> {
365
+ if (signal?.aborted || closed || disconnected || !registered || !socket || pendingSends.size >= MAX_PENDING_SENDS) return Promise.resolve(false);
366
+ const msgId = randomUUID();
367
+ return new Promise((resolve) => {
368
+ const finish = (delivered: boolean): void => {
369
+ if (!pendingSends.delete(msgId)) return;
370
+ clearTimeout(timer);
371
+ signal?.removeEventListener("abort", onAbort);
372
+ resolve(delivered);
373
+ };
374
+ const onAbort = (): void => finish(false);
375
+ const timer = setTimeout(() => finish(false), SEND_TIMEOUT_MS);
376
+ timer.unref?.();
377
+ pendingSends.set(msgId, finish);
378
+ signal?.addEventListener("abort", onAbort, { once: true });
379
+ try { socket!.write(encodeFrame({ t: "send", to, kind, text, msgId, expectsReply: false })); }
380
+ catch { finish(false); }
381
+ });
264
382
  }
265
383
 
266
384
  /** Why `ask`/`list` refuse to send right now — distinguishes a disposed client from one that
@@ -357,6 +475,9 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
357
475
  function close(): void {
358
476
  if (closed) return;
359
477
  closed = true;
478
+ registered = false;
479
+ cancelConnect?.();
480
+ cancelBackoff?.();
360
481
  const s = socket;
361
482
  if (s) {
362
483
  write({ t: "bye" });
@@ -375,5 +496,5 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
375
496
  rejectAllPending("broker client closed");
376
497
  }
377
498
 
378
- return { register, send, ask, reply, list, onDeliver, onSteer, onClose, close };
499
+ return { register, read, send, ask, reply, list, onDeliver, onSteer, onClose, close };
379
500
  }
@@ -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,26 +342,38 @@ 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;
263
373
  }
264
374
  case "send": {
265
375
  if (!handle) {
266
- write({ t: "error", reason: "send before register" });
376
+ write({ t: "error", reason: "send before register", msgId: frame.msgId });
267
377
  return;
268
378
  }
269
379
  const from = handle;
@@ -285,8 +395,12 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
285
395
  forgetAsk(frame.msgId, ac);
286
396
  write({ t: "error", reason: errorReason(error), msgId: frame.msgId });
287
397
  }
288
- } else if (!bus.send(from, frame.to, frame.text, frame.kind)) {
289
- write({ t: "error", reason: `unknown peer: ${frame.to}` });
398
+ } else {
399
+ // Admission is not an answer. Unknown/full inboxes both return false;
400
+ // never label a full inbox "unknown peer" or a socket write "delivered".
401
+ let delivered = false;
402
+ try { delivered = bus.send(from, frame.to, frame.text, frame.kind); } catch { /* no confirmation */ }
403
+ write({ t: "sent", msgId: frame.msgId, delivered });
290
404
  }
291
405
  return;
292
406
  }
@@ -324,6 +438,8 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
324
438
  const handleFrame = (raw: unknown): void => {
325
439
  if (!isFrame(raw)) {
326
440
  write({ t: "error", reason: "invalid frame" });
441
+ if (boundGrant || (typeof raw === "object" && raw !== null
442
+ && ("readerToken" in raw || ("t" in raw && String(raw.t).startsWith("result_read"))))) socket.destroy();
327
443
  return;
328
444
  }
329
445
  try {
@@ -337,8 +453,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
337
453
  }
338
454
  };
339
455
 
340
- socket.on("data", createFrameReader(handleFrame, (e) => {
341
- write({ t: "error", reason: e.message });
456
+ socket.on("data", createFrameReader(handleFrame, () => {
457
+ // JSON parser diagnostics can contain the private registration token.
458
+ write({ t: "error", reason: "invalid frame" });
342
459
  socket.destroy();
343
460
  }));
344
461
  socket.on("error", () => socket.destroy());
@@ -350,9 +467,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
350
467
 
351
468
  const server = await bindServer(netImpl, deps.endpoint, handleConnection);
352
469
 
353
- let closed = false;
354
470
  return {
355
471
  endpoint: deps.endpoint,
472
+ prepareResultReader,
356
473
  connectedHandles: () => [...connections.keys()],
357
474
  steer(handle: string, text: string): boolean {
358
475
  const conn = connections.get(handle);
@@ -370,12 +487,14 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
370
487
  expected.add(handle);
371
488
  },
372
489
  forget(handle: string): void {
490
+ grants.get(handle)?.revoke();
373
491
  expected.delete(handle);
374
492
  pendingSteer.delete(handle);
375
493
  },
376
494
  async close(): Promise<void> {
377
495
  if (closed) return;
378
496
  closed = true;
497
+ for (const grant of grants.values()) grant.revoke();
379
498
  unsubBus();
380
499
  expected.clear();
381
500
  pendingSteer.clear();