pi-crew 0.9.46 → 0.9.48

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 (46) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +16 -2
  3. package/dist/build-meta.json +289 -164
  4. package/dist/index.mjs +1744 -2732
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/decisions/2026-07-21-broker-phase4-default-on.md +77 -0
  7. package/docs/decisions/2026-07-21-broker-windows-perms.md +91 -0
  8. package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +99 -0
  9. package/docs/decisions/README.md +3 -0
  10. package/docs/publishing.md +29 -0
  11. package/package.json +3 -1
  12. package/scripts/build-bundle.mjs +7 -0
  13. package/scripts/postinstall.mjs +60 -1
  14. package/scripts/pty_probe.py +174 -0
  15. package/skills/real-test-pi-crew/SKILL.md +659 -0
  16. package/src/config/config.ts +42 -1
  17. package/src/config/defaults.ts +45 -1
  18. package/src/config/types.ts +19 -0
  19. package/src/extension/register.ts +6 -1
  20. package/src/extension/registration/context-builder.ts +4 -0
  21. package/src/extension/registration/lifecycle-handlers.ts +166 -3
  22. package/src/extension/registration/registration-types.ts +9 -0
  23. package/src/prompt/prompt-runtime.ts +108 -0
  24. package/src/runtime/broker-issuer.ts +37 -0
  25. package/src/runtime/child-pi-spawn.ts +53 -0
  26. package/src/runtime/child-pi.ts +42 -11
  27. package/src/runtime/crew-broker-child.ts +88 -0
  28. package/src/runtime/crew-broker-client.ts +673 -0
  29. package/src/runtime/crew-broker-tokens.ts +84 -0
  30. package/src/runtime/crew-broker.ts +1276 -0
  31. package/src/runtime/dynamic-workflow-context.ts +7 -3
  32. package/src/runtime/dynamic-workflow-runner.ts +1 -1
  33. package/src/runtime/plan-templates.ts +8 -6
  34. package/src/schema/config-schema.ts +14 -0
  35. package/src/state/mailbox.ts +43 -0
  36. package/src/ui/key-utils.ts +42 -0
  37. package/src/ui/keybinding-map.ts +29 -3
  38. package/src/ui/run-dashboard.ts +28 -0
  39. package/src/ui/settings-overlay.ts +42 -22
  40. package/src/utils/ndjson.ts +115 -0
  41. package/src/utils/session-utils.ts +30 -0
  42. package/src/utils/socket-path.ts +127 -0
  43. package/workflows/default.workflow.md +1 -1
  44. package/workflows/fast-fix.workflow.md +1 -1
  45. package/workflows/plan-execute.workflow.md +1 -1
  46. package/workflows/review.workflow.md +1 -1
@@ -0,0 +1,673 @@
1
+ /**
2
+ * crew-broker-client.ts — Child-side connector for the local broker.
3
+ * PHASE 0 skeleton (sub-task 0.3).
4
+ *
5
+ * - Connects only on the first request/subscribe.
6
+ * - Sends `hello` first; any other method before ack is a protocol error.
7
+ * - Bounded backoff (50/100/200/400/800 ms + jitter, max 4 attempts).
8
+ * After exhaustion, no hot reconnect loop is scheduled. The only way back
9
+ * to the live path is an explicit `reconnect()` (or a new worker lifecycle).
10
+ * - On ANY connect/auth/timeout/close error, transitions to `fallback` mode
11
+ * and never lets an exception escape `request()` / `subscribe()`. The
12
+ * caller can read `client.mode` or the `{ok:false, fallback:true}` result
13
+ * and continue using today's file-based paths.
14
+ * - `close()` removes all listeners and pending requests.
15
+ * - All diagnostic strings pass through `redactSecretString` from
16
+ * `src/utils/redaction.ts`. Token and payload bytes are never logged.
17
+ *
18
+ * No process management. No children. No socket listening. Connect-only.
19
+ *
20
+ * See `reports/inter-pi-broker-impl-plan-2026-07-21.md` §"0.3" for the
21
+ * full contract and acceptance criteria.
22
+ */
23
+
24
+ import { randomUUID } from "node:crypto";
25
+ import * as net from "node:net";
26
+
27
+ import { logInternalError } from "../utils/internal-error.ts";
28
+ import { BrokerError, encodeBrokerFrame, NdjsonDecoder } from "../utils/ndjson.ts";
29
+ import { redactSecretString } from "../utils/redaction.ts";
30
+
31
+ /** Shape of an unsolicited event frame pushed by the broker (mailbox.message,
32
+ * team.event, etc.). */
33
+ export interface BrokerEventFrame {
34
+ event: string;
35
+ data: unknown;
36
+ seq?: number;
37
+ }
38
+
39
+ /** Protocol version negotiated at hello. Must match the broker. */
40
+ const BROKER_PROTOCOL = 1;
41
+
42
+ /** Per-attempt timeout for connect + hello. */
43
+ const CONNECT_HELLO_TIMEOUT_MS = 5_000;
44
+
45
+ /** Bounded backoff schedule (ms). At most 4 attempts means 3 retries after
46
+ * the first failure. Jitter is ±25%. */
47
+ const BACKOFF_SCHEDULE_MS: readonly number[] = [50, 100, 200, 400, 800] as const;
48
+ /** Hard cap on the number of attempts. 4 total = 1 initial + 3 retries. */
49
+ const MAX_ATTEMPTS = 4;
50
+
51
+ export type BrokerClientMode = "unstarted" | "connected" | "fallback";
52
+
53
+ export type BrokerClientResult<T> = { ok: true; value: T } | { ok: false; fallback: true; errorCode?: string; error?: Error };
54
+
55
+ export interface CrewBrokerClientOptions {
56
+ runId: string;
57
+ taskId: string;
58
+ /** Pre-resolved socket path. If absent, the client is permanently
59
+ * fallback (caller forgot to wire spawn context). */
60
+ socketPath?: string;
61
+ /** Per-run token. If absent, the client is permanently fallback. */
62
+ token?: string;
63
+ /** Override `process.env` (used by tests). */
64
+ env?: NodeJS.ProcessEnv;
65
+ /** Test seam: override the `net` module. */
66
+ netModule?: typeof net;
67
+ /** Test seam: clock for backoff scheduling. */
68
+ now?: () => number;
69
+ /** Test seam: timer factory. */
70
+ setTimeoutFn?: (cb: () => void, ms: number) => NodeJS.Timeout;
71
+ /** Test seam: clear timer. */
72
+ clearTimeoutFn?: (timer: NodeJS.Timeout) => void;
73
+ /** Test seam: random jitter source (returns a multiplier in [0.75, 1.25]). */
74
+ jitter?: () => number;
75
+ /** Handler for unsolicited event frames pushed by the broker after hello
76
+ * (e.g. `mailbox.message`, `team.event`). When omitted, event frames are
77
+ * silently ignored (Phase 0 behavior). Never throws into the socket loop. */
78
+ onEvent?: (event: BrokerEventFrame) => void;
79
+ }
80
+
81
+ interface PendingRequest {
82
+ id: string;
83
+ method: string;
84
+ resolve: (value: unknown) => void;
85
+ reject: (err: Error) => void;
86
+ }
87
+
88
+ export class CrewBrokerClient {
89
+ private readonly options: Required<Pick<CrewBrokerClientOptions, "runId" | "taskId">> &
90
+ Pick<
91
+ CrewBrokerClientOptions,
92
+ "socketPath" | "token" | "env" | "netModule" | "now" | "setTimeoutFn" | "clearTimeoutFn" | "jitter" | "onEvent"
93
+ >;
94
+ private _mode: BrokerClientMode = "unstarted";
95
+ private socket: net.Socket | null = null;
96
+ private decoder: NdjsonDecoder | null = null;
97
+ private readonly pending = new Map<string, PendingRequest>();
98
+ private attempts = 0;
99
+ /** Listeners attached to the current socket; kept for explicit close(). */
100
+ private readonly socketListeners: Array<{
101
+ event: string;
102
+ listener: (...args: unknown[]) => void;
103
+ }> = [];
104
+ /** Set in close(); gates reconnect attempts and backoff loops. */
105
+ private closed = false;
106
+ /** Handle to the in-flight backoff timer so close() can cancel it. */
107
+ private backoffTimer: NodeJS.Timeout | null = null;
108
+ /** Resolver for the in-flight backoff await so close() can unblock it.
109
+ * Without this, cancelling the timer leaves the connectAndHello await
110
+ * hanging forever (resolve() never fires). */
111
+ private backoffResolver: (() => void) | null = null;
112
+
113
+ constructor(options: CrewBrokerClientOptions) {
114
+ if (!options || typeof options !== "object") {
115
+ throw new Error("CrewBrokerClient: options is required");
116
+ }
117
+ if (typeof options.runId !== "string" || options.runId.length === 0) {
118
+ throw new Error("CrewBrokerClient: runId must be a non-empty string");
119
+ }
120
+ if (typeof options.taskId !== "string" || options.taskId.length === 0) {
121
+ throw new Error("CrewBrokerClient: taskId must be a non-empty string");
122
+ }
123
+ this.options = {
124
+ runId: options.runId,
125
+ taskId: options.taskId,
126
+ socketPath: options.socketPath,
127
+ token: options.token,
128
+ env: options.env,
129
+ netModule: options.netModule,
130
+ now: options.now,
131
+ setTimeoutFn: options.setTimeoutFn,
132
+ clearTimeoutFn: options.clearTimeoutFn,
133
+ jitter: options.jitter,
134
+ onEvent: options.onEvent,
135
+ };
136
+ }
137
+
138
+ get mode(): BrokerClientMode {
139
+ return this._mode;
140
+ }
141
+
142
+ /** Diagnostic: number of currently-pending requests. */
143
+ get pendingCount(): number {
144
+ return this.pending.size;
145
+ }
146
+
147
+ /**
148
+ * Send a request. Returns:
149
+ * - `{ok:true, value}` on success.
150
+ * - `{ok:false, fallback:true, errorCode?}` on any connect/auth/
151
+ * timeout/close/protocol error. The client transitions to fallback
152
+ * for its lifetime; only `reconnect()` may move it back to "unstarted".
153
+ *
154
+ * Never throws. The caller can continue using file-based fallback paths
155
+ * without unwrapping anything.
156
+ */
157
+ async request<T = unknown>(method: string, params: unknown): Promise<BrokerClientResult<T>> {
158
+ if (this._mode === "fallback") {
159
+ return { ok: false, fallback: true, errorCode: "fallback-sticky" };
160
+ }
161
+ if (typeof method !== "string" || method.length === 0) {
162
+ return { ok: false, fallback: true, errorCode: "bad-method" };
163
+ }
164
+
165
+ // Lazy connect + hello on first use.
166
+ if (this._mode === "unstarted" || !this.socket) {
167
+ const connectResult = await this.connectAndHello();
168
+ if (!connectResult.ok) {
169
+ return connectResult;
170
+ }
171
+ }
172
+
173
+ if (!this.socket) {
174
+ return { ok: false, fallback: true, errorCode: "no-socket" };
175
+ }
176
+
177
+ // Send the request. Send a frame FIRST so the server's hello gate
178
+ // cannot reject it as "method other than hello".
179
+ const id = `r-${randomUUID()}`;
180
+ const promise = new Promise<unknown>((resolve, reject) => {
181
+ this.pending.set(id, { id, method, resolve, reject });
182
+ });
183
+ try {
184
+ const frame = encodeBrokerFrame({ id, method, params });
185
+ // Write may emit EPIPE etc. We don't await drain here — the response
186
+ // promise handles the rest.
187
+ this.socket.write(frame);
188
+ } catch (err) {
189
+ // encodeBrokerFrame can throw on oversize. The socket is still
190
+ // alive but the request is bad.
191
+ this.pending.delete(id);
192
+ return { ok: false, fallback: true, errorCode: "encode-failed" };
193
+ }
194
+
195
+ try {
196
+ const value = await promise;
197
+ // Detect the tagged broker-error envelope: per-request errors
198
+ // (bad-params, no-manifest, wait-timeout, etc.) resolve with
199
+ // `{__brokerError:true, code, message}` and do NOT enter fallback.
200
+ if (value && typeof value === "object" && (value as { __brokerError?: boolean }).__brokerError === true) {
201
+ const e = value as { code: string; message: string };
202
+ return { ok: false, fallback: true, errorCode: e.code };
203
+ }
204
+ return { ok: true, value: value as T };
205
+ } catch (err) {
206
+ // Reject from pending handlers → the typed error code.
207
+ const code = err instanceof BrokerError ? err.code : "request-failed";
208
+ this.enterFallbackOnce(code, err);
209
+ return { ok: false, fallback: true, errorCode: code };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Subscribe to a per-run event stream. Phase 0 does not implement
215
+ * events.since — the subscription returns a no-op unsubscribe and
216
+ * transitions to fallback if the connection is lost. The caller can
217
+ * continue using the file poll path.
218
+ */
219
+ subscribe(options: { runId: string; sinceSeq: number; onEvent: (event: unknown) => void }): () => void {
220
+ // Phase 0: subscription is a typed not-implemented. We register a
221
+ // no-op unsubscribe so the caller can call it without errors.
222
+ void options;
223
+ return () => {
224
+ /* no-op */
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Explicit reconnect. Resets `mode` to `unstarted` and clears the
230
+ * backoff counter. Returns true if a fresh connection succeeded,
231
+ * false otherwise (mode becomes `fallback` on failure). Use this
232
+ * after a known broker restart.
233
+ */
234
+ async reconnect(): Promise<boolean> {
235
+ // Close the current socket if any.
236
+ this.teardownSocket();
237
+ this.attempts = 0;
238
+ this._mode = "unstarted";
239
+ const res = await this.request("ping", null);
240
+ return res.ok;
241
+ }
242
+
243
+ /**
244
+ * Close the client. Removes all socket listeners, rejects all pending
245
+ * requests with a clean fallback result, and transitions to fallback
246
+ * (so a subsequent request() returns a typed error rather than
247
+ * reconnecting in the background).
248
+ */
249
+ async close(): Promise<void> {
250
+ // Set the closed flag FIRST so any backoff-loop iteration that
251
+ // resumes after teardownSocket (e.g. after a queued backoff timer
252
+ // fires) sees it and returns immediately with a typed error.
253
+ this.closed = true;
254
+ // Cancel any queued backoff timer. Without this, the in-flight
255
+ // connectAndHello would resume its retry loop after close() and
256
+ // create new sockets against a torn-down client.
257
+ if (this.backoffTimer !== null) {
258
+ try {
259
+ (this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t)))(this.backoffTimer);
260
+ } catch {
261
+ /* ignore — best-effort cancel */
262
+ }
263
+ this.backoffTimer = null;
264
+ }
265
+ // Unblock the in-flight backoff await. Cancelling the timer alone
266
+ // leaves the connectAndHello Promise stuck on resolve(); this lets
267
+ // the loop unblock, observe this.closed, and return typed 'closed'.
268
+ if (this.backoffResolver !== null) {
269
+ const resolveBackoff = this.backoffResolver;
270
+ this.backoffResolver = null;
271
+ try {
272
+ resolveBackoff();
273
+ } catch {
274
+ /* ignore — best-effort unblock */
275
+ }
276
+ }
277
+ this.teardownSocket();
278
+ // Reject every pending request. Using reject (not resolve) so the
279
+ // request() catch block kicks in and returns {ok:false, fallback:true}.
280
+ // Resolving with undefined would have made request() return
281
+ // {ok:true, value:undefined}, which is misleading.
282
+ for (const [, p] of this.pending) {
283
+ p.reject(new BrokerError("close", "client closed"));
284
+ }
285
+ this.pending.clear();
286
+ this._mode = "fallback";
287
+ }
288
+
289
+ // ------------------------------------------------------------------------
290
+ // Connect + hello with bounded backoff
291
+ // ------------------------------------------------------------------------
292
+
293
+ private async connectAndHello(): Promise<BrokerClientResult<true>> {
294
+ if (!this.options.socketPath || !this.options.token) {
295
+ this.enterFallbackOnce("missing-credentials", new Error("socket or token not provided"));
296
+ return { ok: false, fallback: true, errorCode: "missing-credentials" };
297
+ }
298
+
299
+ const netModule = this.options.netModule ?? net;
300
+ const setTimeoutFn = this.options.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
301
+ const clearTimeoutFn = this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t));
302
+ const jitter = this.options.jitter ?? (() => 0.75 + Math.random() * 0.5); // [0.75, 1.25]
303
+
304
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
305
+ // Closed gate at the top of each iteration: catches the case
306
+ // where close() was called while we were awaiting attemptHello()
307
+ // or while a previous backoff timer was firing. After close(),
308
+ // the client must not open any new sockets.
309
+ if (this.closed) {
310
+ return { ok: false, fallback: true, errorCode: "closed" };
311
+ }
312
+ this.attempts = attempt + 1;
313
+ const result = await this.attemptHello(netModule);
314
+ if (result.ok) {
315
+ // Belt-and-suspenders: a successful hello can still race
316
+ // with close() (e.g. close() called between the hello ack
317
+ // being processed and this return). If we are now closed,
318
+ // tear down the socket we just opened and return the typed
319
+ // error rather than handing back a connected client.
320
+ if (this.closed) {
321
+ this.teardownSocket();
322
+ this._mode = "fallback";
323
+ return { ok: false, fallback: true, errorCode: "closed" };
324
+ }
325
+ this._mode = "connected";
326
+ return result;
327
+ }
328
+ // Failure. If we are out of attempts, give up.
329
+ if (attempt + 1 >= MAX_ATTEMPTS) {
330
+ this.enterFallbackOnce(result.errorCode ?? "connect-failed", result.error);
331
+ return { ok: false, fallback: true, errorCode: result.errorCode ?? "connect-failed" };
332
+ }
333
+ // Wait the next backoff slot (with jitter), unless this was a
334
+ // auth failure (no point retrying — server rejected hello).
335
+ if (result.errorCode === "auth") {
336
+ this.enterFallbackOnce("auth", result.error);
337
+ return { ok: false, fallback: true, errorCode: "auth" };
338
+ }
339
+ const base = BACKOFF_SCHEDULE_MS[attempt] ?? 800;
340
+ const delay = Math.max(1, Math.floor(base * jitter()));
341
+ await new Promise<void>((resolve) => {
342
+ // Capture the resolver so close() can unblock the await
343
+ // even after cancelling the timer (timer cancellation alone
344
+ // leaves resolve() unfired). We also clear the resolver
345
+ // when the timer fires so a stale handle never leaks.
346
+ this.backoffResolver = resolve;
347
+ const t = setTimeoutFn(() => {
348
+ if (this.backoffResolver === resolve) this.backoffResolver = null;
349
+ this.backoffTimer = null;
350
+ resolve();
351
+ }, delay);
352
+ this.backoffTimer = t;
353
+ if (t && typeof (t as { unref?: () => void }).unref === "function") {
354
+ (t as { unref?: () => void }).unref?.();
355
+ }
356
+ });
357
+ // Unused but kept for symmetry with the future test seam.
358
+ void clearTimeoutFn;
359
+ }
360
+
361
+ // Defensive: the loop always returns.
362
+ this.enterFallbackOnce("exhausted", new Error("backoff exhausted"));
363
+ return { ok: false, fallback: true, errorCode: "exhausted" };
364
+ }
365
+
366
+ private attemptHello(netModule: typeof net): Promise<BrokerClientResult<true>> {
367
+ return new Promise<BrokerClientResult<true>>((resolve) => {
368
+ // Belt-and-suspenders closed gate: the backoff loop in
369
+ // connectAndHello already checks this.closed, but attemptHello
370
+ // can also be entered via reconnect() after the flag is set.
371
+ if (this.closed) {
372
+ resolve({ ok: false, fallback: true, errorCode: "closed" });
373
+ return;
374
+ }
375
+ const sock = netModule.createConnection(this.options.socketPath!);
376
+ let settled = false;
377
+ const finish = (v: BrokerClientResult<true>) => {
378
+ if (settled) return;
379
+ settled = true;
380
+ // Cancel the per-attempt deadline timer so it cannot fire after
381
+ // a successful handshake and destroy the healthy connection.
382
+ try {
383
+ clearTimeoutFn(timer);
384
+ } catch {
385
+ /* ignore */
386
+ }
387
+ try {
388
+ sock.removeAllListeners();
389
+ } catch {
390
+ /* ignore */
391
+ }
392
+ resolve(v);
393
+ };
394
+ this.socket = sock;
395
+ this.decoder = new NdjsonDecoder();
396
+
397
+ // Per-attempt deadline.
398
+ const setTimeoutFn = this.options.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
399
+ const clearTimeoutFn = this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t));
400
+ const timer = setTimeoutFn(() => {
401
+ finish({ ok: false, fallback: true, errorCode: "timeout" });
402
+ try {
403
+ sock.destroy();
404
+ } catch {
405
+ /* ignore */
406
+ }
407
+ }, CONNECT_HELLO_TIMEOUT_MS);
408
+ if (timer && typeof (timer as { unref?: () => void }).unref === "function") {
409
+ (timer as { unref?: () => void }).unref?.();
410
+ }
411
+
412
+ const onConnect = () => {
413
+ // Send hello immediately on connect.
414
+ const hello = {
415
+ id: `hello-${randomUUID()}`,
416
+ method: "hello",
417
+ params: {
418
+ protocol: BROKER_PROTOCOL,
419
+ runId: this.options.runId,
420
+ taskId: this.options.taskId,
421
+ token: this.options.token,
422
+ },
423
+ };
424
+ try {
425
+ sock.write(encodeBrokerFrame(hello));
426
+ } catch (err) {
427
+ finish({ ok: false, fallback: true, errorCode: "encode-failed" });
428
+ try {
429
+ sock.destroy();
430
+ } catch {
431
+ /* ignore */
432
+ }
433
+ return;
434
+ }
435
+ };
436
+ const onData = (chunk: Buffer | string) => {
437
+ if (!this.decoder) return;
438
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
439
+ let frames: unknown[];
440
+ try {
441
+ frames = this.decoder.push(buf);
442
+ } catch (err) {
443
+ const code = err instanceof BrokerError ? err.code : "protocol";
444
+ finish({ ok: false, fallback: true, errorCode: code });
445
+ try {
446
+ sock.destroy();
447
+ } catch {
448
+ /* ignore */
449
+ }
450
+ return;
451
+ }
452
+ for (const frame of frames) {
453
+ if (!isResponseObject(frame)) {
454
+ finish({ ok: false, fallback: true, errorCode: "protocol" });
455
+ try {
456
+ sock.destroy();
457
+ } catch {
458
+ /* ignore */
459
+ }
460
+ return;
461
+ }
462
+ // Hello ack: must include `result.ok === true` and matching id.
463
+ if (frame.id && frame.id.startsWith("hello-")) {
464
+ if (frame.error) {
465
+ const code = (frame.error as { code?: string }).code ?? "auth";
466
+ finish({ ok: false, fallback: true, errorCode: code });
467
+ try {
468
+ sock.destroy();
469
+ } catch {
470
+ /* ignore */
471
+ }
472
+ return;
473
+ }
474
+ // Hello succeeded — wire up the response handler.
475
+ finish({ ok: true, value: true });
476
+ this.wireSocketHandlers(sock);
477
+ return;
478
+ }
479
+ // Otherwise it's a response to a request we sent.
480
+ const pending = this.pending.get(frame.id);
481
+ if (pending) {
482
+ this.pending.delete(frame.id);
483
+ if (frame.error) {
484
+ const code = (frame.error as { code?: string }).code ?? "request-failed";
485
+ pending.reject(new BrokerError(code as never, "request error"));
486
+ } else {
487
+ pending.resolve(frame.result);
488
+ }
489
+ }
490
+ }
491
+ };
492
+ const onError = (err: Error & { code?: string }) => {
493
+ // ECONNREFUSED, EMFILE, ENOSPC, EPERM, ENOENT, EPIPE, etc.
494
+ const code = err.code ?? "connect-failed";
495
+ finish({ ok: false, fallback: true, errorCode: code });
496
+ };
497
+ const onClose = () => {
498
+ finish({ ok: false, fallback: true, errorCode: "close" });
499
+ };
500
+ sock.once("connect", onConnect);
501
+ sock.on("data", onData);
502
+ sock.once("error", onError);
503
+ sock.once("close", onClose);
504
+ // Keep timer for explicit cleanup.
505
+ this.socketListeners.push(
506
+ { event: "connect", listener: onConnect as (...args: unknown[]) => void },
507
+ { event: "data", listener: onData as (...args: unknown[]) => void },
508
+ { event: "error", listener: onError as (...args: unknown[]) => void },
509
+ { event: "close", listener: onClose as (...args: unknown[]) => void },
510
+ );
511
+ void clearTimeoutFn;
512
+ });
513
+ }
514
+
515
+ private wireSocketHandlers(sock: net.Socket): void {
516
+ // After hello, attach persistent handlers. The once-handlers from
517
+ // attemptHello are already removed in finish().
518
+ // Unref the socket so a still-open broker connection never keeps a
519
+ // finished child worker's event loop alive (mirrors the file-poll
520
+ // steering timer's unref). Pushes still arrive while the worker runs.
521
+ (sock as { unref?: () => void }).unref?.();
522
+ const onData = (chunk: Buffer | string) => {
523
+ if (!this.decoder) return;
524
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
525
+ let frames: unknown[];
526
+ try {
527
+ frames = this.decoder.push(buf);
528
+ } catch (err) {
529
+ // Malformed frame — fall back.
530
+ this.enterFallbackOnce("protocol", err);
531
+ try {
532
+ sock.destroy();
533
+ } catch {
534
+ /* ignore */
535
+ }
536
+ return;
537
+ }
538
+ for (const frame of frames) {
539
+ // Distinguish unsolicited event frames (e.g. mailbox.message
540
+ // pushed by the broker's observer-driven live fanout) from
541
+ // request-response frames. Events have `event` + `data` (+ optional
542
+ // `seq`); responses have `id` + `result` or `error`. Event frames
543
+ // are routed to the optional `onEvent` handler; a missing handler
544
+ // silently drops them (the durable path remains authoritative).
545
+ if (isEventFrame(frame)) {
546
+ const handler = this.options.onEvent;
547
+ if (handler) {
548
+ const ev = frame as Record<string, unknown>;
549
+ try {
550
+ handler({ event: ev.event as string, data: ev.data, seq: typeof ev.seq === "number" ? ev.seq : undefined });
551
+ } catch {
552
+ /* an onEvent handler fault must never break the socket loop */
553
+ }
554
+ }
555
+ continue;
556
+ }
557
+ if (!isResponseObject(frame)) {
558
+ this.enterFallbackOnce("protocol", new Error("malformed response"));
559
+ return;
560
+ }
561
+ const pending = this.pending.get(frame.id);
562
+ if (!pending) continue;
563
+ this.pending.delete(frame.id);
564
+ if (frame.error) {
565
+ // Per-request error response from the broker: NOT a protocol
566
+ // error, NOT a fallback condition. The broker explicitly rejected
567
+ // this call (e.g. bad-params, no-manifest, wait-timeout).
568
+ // Resolve with a tagged envelope so request() can return
569
+ // ok:false WITHOUT triggering fallback mode.
570
+ const errCode = (frame.error as { code?: string }).code ?? "request-failed";
571
+ pending.resolve({
572
+ __brokerError: true,
573
+ code: errCode,
574
+ message: (frame.error as { message?: string }).message ?? "",
575
+ } as never);
576
+ } else {
577
+ pending.resolve(frame.result);
578
+ }
579
+ }
580
+ };
581
+ const onError = (err: Error & { code?: string }) => {
582
+ this.enterFallbackOnce(err.code ?? "socket-error", err);
583
+ };
584
+ const onClose = () => {
585
+ // Reject every pending with a close error so request() returns
586
+ // {ok:false, fallback:true}. (Resolving with undefined previously
587
+ // returned {ok:true, value:undefined}, which is misleading.)
588
+ for (const [, p] of this.pending) {
589
+ p.reject(new BrokerError("close", "socket closed"));
590
+ }
591
+ this.pending.clear();
592
+ this.enterFallbackOnce("close", new Error("socket closed"));
593
+ };
594
+ sock.on("data", onData);
595
+ sock.once("error", onError);
596
+ sock.once("close", onClose);
597
+ this.socketListeners.push(
598
+ { event: "data", listener: onData as (...args: unknown[]) => void },
599
+ { event: "error", listener: onError as (...args: unknown[]) => void },
600
+ { event: "close", listener: onClose as (...args: unknown[]) => void },
601
+ );
602
+ }
603
+
604
+ private teardownSocket(): void {
605
+ if (!this.socket) {
606
+ this.socket = null;
607
+ this.decoder = null;
608
+ this.socketListeners.length = 0;
609
+ return;
610
+ }
611
+ const sock = this.socket;
612
+ for (const l of this.socketListeners) {
613
+ try {
614
+ sock.removeListener(l.event, l.listener);
615
+ } catch {
616
+ /* ignore */
617
+ }
618
+ }
619
+ this.socketListeners.length = 0;
620
+ try {
621
+ sock.destroy();
622
+ } catch {
623
+ /* ignore */
624
+ }
625
+ this.socket = null;
626
+ this.decoder = null;
627
+ }
628
+
629
+ private enterFallbackOnce(code: string, cause?: unknown): void {
630
+ if (this._mode === "fallback") return;
631
+ this._mode = "fallback";
632
+ // Log exactly ONE diagnostic per transition. We redact any cause string
633
+ // so token-like bytes cannot leak via the error message.
634
+ const safeCause = cause instanceof Error ? cause.message : cause ? String(cause) : undefined;
635
+ const safe = safeCause ? redactSecretString(safeCause) : undefined;
636
+ logInternalError(
637
+ "crew-broker.client.fallback",
638
+ new Error(`fallback (${code})`),
639
+ `runId=${this.options.runId}${safe ? ` cause=${safe}` : ""}`,
640
+ );
641
+ }
642
+ }
643
+
644
+ // ============================================================================
645
+ // Type guards (no `any`)
646
+ // ============================================================================
647
+
648
+ function isResponseObject(value: unknown): value is { id: string; result?: unknown; error?: { code?: string; message?: string } } {
649
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
650
+ const v = value as Record<string, unknown>;
651
+ if (typeof v.id !== "string" || v.id.length === 0 || v.id.length > 256) return false;
652
+ if ("error" in v) {
653
+ const e = v.error;
654
+ if (!e || typeof e !== "object" || Array.isArray(e)) return false;
655
+ const errObj = e as Record<string, unknown>;
656
+ if (typeof errObj.code !== "string") return false;
657
+ if (typeof errObj.message !== "string") return false;
658
+ }
659
+ return true;
660
+ }
661
+
662
+ /**
663
+ * Detect an unsolicited event frame (no `id`, has `event` + `data`).
664
+ * Event frames are pushed by the broker's live-fanout (e.g. mailbox.message)
665
+ * and must NOT be treated as request responses — otherwise the client's
666
+ * strict response validator would fall back on every push.
667
+ */
668
+ function isEventFrame(value: unknown): boolean {
669
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
670
+ const v = value as Record<string, unknown>;
671
+ if (typeof v.event !== "string" || v.event.length === 0) return false;
672
+ return "data" in v;
673
+ }