pi-crew 0.11.48 → 0.11.50

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.48",
3
+ "version": "0.11.50",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -197,6 +197,13 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
197
197
 
198
198
  > **2026-10-24 update (wave 12 — test-fixture teardown, commits `75542295`→`a97714d2` + wave-12c F4/F6; spec `fixwave12-fixture-teardown-2026-10-24/SPEC.md`)**: lớp leak THỨ HAI, khác wave 11: 2 process `node -e "process.on('SIGTERM',()=>{}); setInterval(...)"` sống >6 h sau khi run `team_20261024024905_4cf882558c7c2b6d` completed — **test fixture**, không phải worker descendant: spawn `detached:true` (group riêng ⇒ group kill của runner không tới được) + teardown pid-only `child.kill()` (vô hiệu trước fixture nuốt SIGTERM) + không handle ⇒ không ai reap. Wave-11 sweep từ chối reap là **hành vi ĐÚNG** (không proof descent). (1) **Surface mới `test/helpers/fixture-process.ts`** — `spawnFixture` (THROW khi `detached:false`: child non-detached chia sẻ group của RUNNER, group kill sẽ giết runner + mọi file sibling; snapshot `readProcIdentity` NGAY sau spawn, normalise pgid=pid), `reapFixture` (group reap + escalation SIGTERM→SIGKILL, refusal chứng minh pid lạ ⇒ KHÔNG signal gì, pid-fallback cho non-Linux, không bao giờ throw), `registerFixtureTrap` (`after()` + `process.on("exit")` sync flush cho cửa sổ abort sớm). Rule viết 1 lần: `test/helpers/README.md`. (2) **GAP-1 (HIGH, wave 12c-F3)** — `background-runner-sigint-cleanup`: grandchild detached của harness giờ được reap theo group trong `finally` (harness giữ pid-only ĐÚNG — non-detached). (3) **GAP-2/GAP-3 (wave 12c-F3)** — fake `rg` tự ghi pid + reap group; `inference-broker` giữ `ChildProcess`, spawn detached + `reapFixture`/trap. (4) **GAP-4 (wave 12c-F4)** — `reap-group.test.ts`: teardown qua `reapTree` (leader-snapshot + group reap **TRƯỚC**, rồi pid kills idempotent) và `spawnTree()` tự reap group trên đường FAIL (timeout 15 s / spawn error / JSON hỏng) — trước đó leader biết, kids không ⇒ mồ côi toàn bộ. GAP-5 sweep: `guest-zombie` (detached thật) chuyển sang helper; 4 file còn lại là NON-detached ⇒ giữ pid kill + comment `// non-detached: pid-only by design (shares runner's group)`. (5) **Wave-12a bug tự gây** — thêm `test/unit/helpers/` mà không đăng ký shard weight ⇒ 4 test shard-partition fail (`ebe0138c`). (6) Battery cho wave này: T1 từng file đã sửa ×2 + `pgrep -af "setInterva[l]"` RỖNG + `test:unit` không tăng fail so baseline. (7) **Wave 12d lesson** — teardown test có async writer (`appendEventAsync` → `fs.promises.mkdir`) phải dùng `removeDirWithRetry` (`test/helpers/rm-retry.ts`: settle 250 ms + retry EBUSY/EPERM/ENOTEMPTY); `rmSync` trần ⇒ `ENOTEMPTY` dưới load vì writer tái tạo `runs/<runId>/` sau khi dir đã bị xoá.
199
199
 
200
+ > **2026-10-28 update (incident 0.11.46/0.11.47 silent-worker + v0.11.48 hotfix + battery HARD-GATE, commits `eed005b3`(46)/`68788df4`(47)/`d55632d0`+`840900b3`(48)/`85de1893`(gate))**: (1) **INCIDENT (live-caught bởi chính tier này)** — LOW-1 stdin handshake (0.11.46) ship default-ON: reader worker-side `startChildBrokerClientAsync` → `readStdinFirstLine` (env broker identity + KHÔNG env token) làm toàn bộ `pi --mode json -p` worker **exit 0 với 0 byte stdout trong ~1.2s**; mọi team run/subagent trên 0.11.46–0.11.47 chết. **Fingerprint regression trong events: `empty-or-stderr-only-result`** — T7 giờ PHẢI grep chuỗi này trong events của run smoke (judge từ DISK, không tin prose report của pi -p). CI mù hoàn toàn: unit dùng seam `readStdinLine` (không chạy process.stdin thật trong pi thật), integration chạy fake-pi. (2) **v0.11.48 flip** — `PI_CREW_BROKER_STDIN` giờ **OPT-IN** (`=1` bật stdin route; unset/`0` = legacy env route bit-for-bit pre-0.11.46, cả 2 phía — `resolveBrokerStdinHandshakeEnabled` child-pi-spawn.ts + `resolveBrokerStdinFallbackEnabled` crew-broker-child.ts). LOW-1 code giữ nguyên chờ root-cause reader (strace: DNS → HTTPS :443 Cloudflare → rt_sigaction SIGTERM → exit_group(0) — ĐÃ giải thích + fix: xem update 2026-10-28-b bên dưới). Regression live test mới: `test/integration/broker-stdin-reader-liveworker.test.ts` (spawn pi thật + env shape chết, assert stdout>0, tự-skip khi không có `pi`). (3) **BATTERY HARD-GATE** — `scripts/pre-publish-battery.mjs` cắm vào `.githooks/pre-push` (sau unit, push main) + `prepublish-ci.mjs` (trước `npm run ci`): T1/T3/T11j luôn + T7 path-triggered từ diff tag-trước→HEAD; skip-không-pi CHỈ qua `PI_CREW_BATTERY_SKIP=1` (ghi audit `.crew/audit/battery.jsonl`; skip im lặng = ĐỎ); quy tắc **gate KHÔNG CHẠY = KHÔNG PUBLISH**. Script là lớp AUTO — battery thủ công theo decision table (tier còn lại: T2/T4/T5/T6/T8/T9/T10/T11c/T12/T13) VẪN bắt buộc theo changed paths và KHÔNG được cherry-pick tier rẻ. (4) **META-RULE (bài học 2 bản sót liền)** — đổi code ảnh hưởng behavior mà skill này mô tả ⇒ **update SKILL.md TRONG CộNG WAVE, TRƯỚC khi chạy battery**; battery chạy FULL decision-table cho mọi changed path. Gate prose bị bỏ 2/2 lần dưới áp lực; chỉ gate script (exit-code) sống sót — đó là lý do (3) tồn tại.
201
+
202
+
203
+ > **2026-10-28-b update (ROOT-CAUSE CLOSED + fd3 transport + review-wave fixes, commits `38d5ed58`..`5190e66b`, cold-verified review + 3-agent verification)**: (1) **ROOT-CAUSE incident 0.11.46/47 (đóng)** — pi's `readPipedStdin` (pi@1.0.4 `dist/main.js:52-65`) đọc TOÀN BỘ piped stdin làm PROMPT CONTENT trong mọi mode trừ `--mode=rpc` ⇒ handshake fd0 race 3 cách: token JSON thành literal prompt text (SECRET LEAK — chứng minh trên transcript thật), stream-ended creds-less câm lặng, reader `pause()` trước EOF drain → pi boot await deadlock → **TỰ exit 0** (KHÔNG phải parent SIGTERM — strace cũ misread) + takeOverStdout buffer ⇒ ~0 bytes. **Fix: handshake dời sang fd3** (stdio entry 4 `['ignore','pipe','pipe','pipe']`, `readFirstLineFromFd(BROKER_HANDSHAKE_FD=3)`; fd0 về `"ignore"` — shape pre-0.11.46); detached runner GIỮ fd0 (node process, không race). Gate `PI_CREW_BROKER_STDIN` vẫn OPT-IN. Evidence: pre-fix pi@1.0.4 gate-ON 5/5 chết (~1s, 0B) → post-fix 2/2 sống (106-110KB, ~3.2s, 0 leak); integration live-worker giờ assert CẢ gate-ON liveness (bỏ comment "DO NOT assert liveness") + leak-check `v":2` không xuất hiện trong output. **Contract nền: fd0 KHÔNG BAO GIỜ là kênh sideband của extension khi spawn pi binary — dùng stdio entry 4+ (fd3).** (2) **T7 judge mechanics ĐỔI** — dispatch goal giờ mang nonce `[t7-nonce:<uuid>]`, judge CHỈ dirs có nonce trong events.jsonl (hết race newest-mtime với run đồng thời); non-terminal run = FAIL (check TRƯỚC killed-branch); stdout/stderr của dispatch được capture (cap 64KB) vào failure reason + audit. (3) **T7 triggers mở rộng** — thêm `src/(state|utils|agents|teams|workflows|hooks|observability)/` + `scripts/` (gate changes live-test the gate). (4) diff-base tag filter `^v\d+\.\d+\.\d+$` + porcelain rename both-sides; `PI_CREW_BATTERY_SKIP` giờ sống qua prepublishOnly path. (5) maxTasksPerRun admission cap tại createRunManifest (pre-dispatch); seam fix gate đọc `options.env ?? process.env` (test inject được — hết mutate process.env); `buildBrokerStdinLine` warn >60KiB (reader abort ~131KiB thực tế Linux — pipe chunks). (6) **CI 'Checks' step có gates NGOÀI battery list** (`check:env-vars`, `check:decision-drift`) — thêm vào leader local gate list sau mọi src edit.
204
+
205
+ > **2026-10-28-c update (LOW-1 DEFAULT-ON flip — commit riêng ngay sau `deb09c5e`/v0.11.49)**: `PI_CREW_BROKER_STDIN` gate lật **DEFAULT-ON** — cả 2 gate (`resolveBrokerStdinHandshakeEnabled` child-pi-spawn.ts + `resolveBrokerStdinFallbackEnabled` crew-broker-child.ts) đổi `=== "1"` → `!== "0"` (chỉ đúng `0` opt-out; unset/`1`/bất kỳ giá trị khác = ON — kể cả garbage như `true`). Fix-1 spawner vẫn ghi `"1"` vào built.env khi gate ON (pin route + tương thích worker pre-flip); parent opt-out ⇒ token ở env child (worker reader tự disarm). T7 live-worker test có thêm pin DEFAULT (gate unset + fd3 payload → alive + no-leak). **Meta-rule của update 2026-10-28 (1) vẫn nguyên: flip này là ĐÚNG lớp incident 0.11.46/47 — battery T7 REAL-TEST BẮT BUỘC trước publish, không chỉ CI.** Skew case mới: gate unset + KHÔNG fd3 pipe (old-parent shape) → `readFirstLineFromFd(3)` missing-fd contract = undefined (bounded, không treo) — pin bởi test đầu của `broker-stdin-reader-liveworker.test.ts`.
206
+
200
207
  ## Core principle: disk ≠ live Pi
201
208
 
202
209
  Two locations hold pi-crew state:
@@ -199,8 +199,8 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
199
199
  },
200
200
  PI_CREW_BROKER_TASK_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
201
201
  PI_CREW_BROKER_STDIN: {
202
- kind: "host-only",
203
- reason: "LOW-1 closure broker-credential transport gate — read by the host spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route; the worker-side mirror read (crew-broker-child .resolveBrokerStdinFallbackEnabled) is defense-in-depth for the rollback case and needs NO ambient forwarding (parent decision fully determines the route; forwarding would churn the pinned BASE_ALLOWLIST)",
202
+ kind: "child-write",
203
+ reason: "LOW-1 broker-credential transport gate (DEFAULT-ON since 2026-10-28 — only exact '0' opts out) — read by the HOST spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route, then RE-WRITTEN by that spawner into every gate-ON brokered child's built.env ('1') so the worker-side mirror gate (crew-broker-child .resolveBrokerStdinFallbackEnabled, which reads its OWN process.env) is armed deterministically and pre-flip workers (mirror === '1') stay compatible — ambient forwarding never reaches the child, so child-write is the only channel that wires the route through (FIX-1 2026-10-28). Never ambient-allowlisted: route selection is per-spawn",
204
204
  },
205
205
  PI_CREW_INFERENCE_BROKER: {
206
206
  kind: "gap",
@@ -668,7 +668,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
668
668
  },
669
669
  PI_CREW_BROKER_STDIN: {
670
670
  name: "PI_CREW_BROKER_STDIN",
671
- doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, OPT-IN since v0.11.48 (2026-10-28): '1' sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over the spawn STDIN pipe (one buildBrokerStdinLine v2 payload; heap → pipe → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader) so the token never appears in the child env; unset/any-other-value (default) keeps the LEGACY ENV ROUTE (token in built.env, stdin stays 'ignore') on BOTH sides — the stdin reader path was caught by the live battery (real-test-pi-crew T7) killing real headless workers with a silent exit 0 and is disabled-by-default until root-caused. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side stdin read skips --mode rpc argv and TTY stdin by design)",
671
+ doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, DEFAULT-ON since 2026-10-28 (was OPT-IN v0.11.48–0.11.49): unset (default)/'1'/any value other than exactly '0' sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over a DEDICATED fd3 spawn pipe (one buildBrokerStdinLine v2 payload on stdio entry 4; heap → fd3 → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader; fd0 stays 'ignore') so the token never appears in the child env; exactly '0' opts OUT (rollback to the LEGACY ENV ROUTE — token in built.env — on BOTH sides) — the original fd0 variant was caught by the live battery (real-test-pi-crew T7) killing real headless workers with a silent exit 0 (root cause: pi@1.0.4 readPipedStdin consumes all piped fd0 as prompt content); the fd3 transport removes that race. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side fd3 read skips --mode rpc argv by design)",
672
672
  },
673
673
  PI_CREW_GUEST: {
674
674
  name: "PI_CREW_GUEST",
@@ -202,8 +202,15 @@ export function registerAutonomousPolicy(pi: ExtensionAPI): void {
202
202
  const autonomous = effectiveAutonomousConfig(loaded.config.autonomous);
203
203
  if (!autonomous.enabled) return undefined;
204
204
  if (!autonomous.injectPolicy) return undefined;
205
+ const prompt = typeof event.prompt === "string" ? event.prompt : "";
206
+ // `prompt` is absent on turns with no user text (bg-task notifications, goal
207
+ // continuations, timer turns); unguarded it reaches prompt.toLowerCase() and throws.
208
+ // FIX-8 (GH #64 wave, defense-in-depth): `systemPrompt` is typed `string`
209
+ // and the runner always sets it, but coerce anyway — the output is built
210
+ // FROM it, so an undefined would stringify as a literal "undefined" prefix.
211
+ const systemPrompt = typeof event.systemPrompt === "string" ? event.systemPrompt : "";
205
212
  return {
206
- systemPrompt: appendAutonomousPolicy(event.systemPrompt, event.prompt, autonomous, cwd),
213
+ systemPrompt: appendAutonomousPolicy(systemPrompt, prompt, autonomous, cwd),
207
214
  };
208
215
  });
209
216
  }
@@ -474,6 +474,10 @@ export function registerKnowledgeInjection(pi: ExtensionAPI): void {
474
474
  const cwd = typeof options.cwd === "string" ? options.cwd : process.cwd();
475
475
  const fragment = buildKnowledgeFragment(cwd);
476
476
  if (!fragment) return undefined;
477
- return { systemPrompt: `${event.systemPrompt}${fragment}` };
477
+ // FIX-8 (GH #64 wave, defense-in-depth): `systemPrompt` is typed `string`
478
+ // and the runner always sets it, but coerce anyway — the output is built
479
+ // FROM it, so an undefined would stringify as a literal "undefined" prefix.
480
+ const systemPrompt = typeof event.systemPrompt === "string" ? event.systemPrompt : "";
481
+ return { systemPrompt: `${systemPrompt}${fragment}` };
478
482
  });
479
483
  }
@@ -1191,10 +1191,11 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
1191
1191
  // deliver pushed steers with the SAME sanitize + pi.sendMessage path as the
1192
1192
  // file poll above. The file poll remains the durable fallback; a broker
1193
1193
  // connect failure is invisible to the worker.
1194
- // LOW-1 closure: the registration hook is SYNC, but the credential source
1195
- // may now be the spawn stdin pipe (one buildBrokerStdinLine line) — resolve
1194
+ // LOW-1 closure → FD-3: the registration hook is SYNC, but the credential
1195
+ // source may now be the DEDICATED fd3 spawn pipe (one buildBrokerStdinLine
1196
+ // line — fd0 stays "ignore"; pi@1.0.4 readPipedStdin owns fd0) — resolve
1196
1197
  // the async bootstrap into a placeholder handle and swap it in when ready.
1197
- // A session_shutdown racing the (bounded ≤2s) stdin read closes whichever
1198
+ // A session_shutdown racing the (bounded ≤2s) fd3 read closes whichever
1198
1199
  // handle is live at that moment, and the late arrival closes itself.
1199
1200
  let brokerHandle: ChildBrokerClientHandle = { active: false, close: async () => undefined };
1200
1201
  let brokerShutdownSeen = false;
@@ -1356,6 +1357,13 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
1356
1357
 
1357
1358
  // ── Prompt rewriting (existing) ────────────────────────────────────────
1358
1359
  pi.on("before_agent_start", (event) => {
1360
+ // FIX-8 (GH #64 wave, defense-in-depth): `systemPrompt` is typed `string`
1361
+ // and the runner always sets it. If a host ever hands a non-string through,
1362
+ // SKIP the rewrite entirely — coercing "" here would REPLACE the real
1363
+ // system prompt with a blank one (this handler strips sections), unlike
1364
+ // the append-only hooks (autonomous-policy / knowledge-injection) which
1365
+ // safely coerce "".
1366
+ if (typeof event.systemPrompt !== "string") return;
1359
1367
  const inheritProjectContext = readBooleanEnvAny(PI_CREW_INHERIT_PROJECT_CONTEXT_ENV, PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV);
1360
1368
  const inheritSkills = readBooleanEnvAny(PI_CREW_INHERIT_SKILLS_ENV, PI_TEAMS_INHERIT_SKILLS_ENV);
1361
1369
  if (inheritProjectContext === undefined && inheritSkills === undefined) return;
@@ -17,7 +17,7 @@
17
17
  import type * as net from "node:net";
18
18
  import { getCrewEnv } from "../../config/env-vars.ts";
19
19
  import { type BrokerEventFrame, CrewBrokerClient, type CrewBrokerClientOptions } from "./crew-broker-client.ts";
20
- import { parseStdinBrokerPayload, readStdinFirstLine } from "./stdin-handshake.ts";
20
+ import { BROKER_HANDSHAKE_FD, parseStdinBrokerPayload, readFirstLineFromFd } from "./stdin-handshake.ts";
21
21
 
22
22
  export interface ChildBrokerClientHandle {
23
23
  /** True once the client has completed the handshake. */
@@ -39,9 +39,10 @@ export interface StartChildBrokerClientOptions {
39
39
  netModule?: typeof net;
40
40
  /** Test seam: override client construction. */
41
41
  clientFactory?: (opts: CrewBrokerClientOptions) => CrewBrokerClient;
42
- /** Test seam: override the stdin first-line read for the LOW-1 fallback
43
- * (defaults to stdin-handshake.readStdinFirstLine on real process.stdin). */
44
- readStdinLine?: () => Promise<string | undefined>;
42
+ /** Test seam: override the handshake first-line read for the LOW-1 fd3
43
+ * fallback (defaults to stdin-handshake.readFirstLineFromFd(3) on the
44
+ * dedicated spawn stdio pipe — NOT fd0, which pi owns for its prompt). */
45
+ readHandshakeLine?: () => Promise<string | undefined>;
45
46
  }
46
47
 
47
48
  const NOOP_HANDLE: ChildBrokerClientHandle = {
@@ -51,10 +52,10 @@ const NOOP_HANDLE: ChildBrokerClientHandle = {
51
52
 
52
53
  export function startChildBrokerClient(options: StartChildBrokerClientOptions = {}): ChildBrokerClientHandle {
53
54
  const env = options.env ?? process.env;
54
- const socketPath = env.PI_CREW_BROKER_SOCKET;
55
- const token = env.PI_CREW_BROKER_TOKEN;
56
- const runId = env.PI_CREW_BROKER_RUN_ID;
57
- const taskId = env.PI_CREW_BROKER_TASK_ID;
55
+ const socketPath = getCrewEnv("PI_CREW_BROKER_SOCKET", env);
56
+ const token = getCrewEnv("PI_CREW_BROKER_TOKEN", env);
57
+ const runId = getCrewEnv("PI_CREW_BROKER_RUN_ID", env);
58
+ const taskId = getCrewEnv("PI_CREW_BROKER_TASK_ID", env);
58
59
  if (!socketPath || !token || !runId || !taskId) {
59
60
  return NOOP_HANDLE;
60
61
  }
@@ -96,33 +97,59 @@ export function startChildBrokerClient(options: StartChildBrokerClientOptions =
96
97
  * its stdin IS the JSONL protocol channel and must never be consumed by the
97
98
  * credential handshake. Those spawns keep the token in env (the RPC branch of
98
99
  * child-pi.ts never strips it), so the fallback below is unreachable there
99
- * anyway — this guard is defense-in-depth against hybrid/old-parent skew. */
100
+ * anyway — this guard is defense-in-depth against hybrid/old-parent skew.
101
+ * FIX-2 (2026-10-28): match BOTH argv spellings — the pi-args builder emits
102
+ * the two-argv form ("--mode rpc"), but wrappers/users can pass the equals
103
+ * form ("--mode=rpc"); a spelling miss here would let the fallback eat a
104
+ * protocol line off the RPC JSONL channel. */
100
105
  function argvIndicatesRpcMode(): boolean {
101
- return process.argv.includes("--mode") && process.argv.includes("rpc");
106
+ return process.argv.includes("--mode=rpc") || (process.argv.includes("--mode") && process.argv.includes("rpc"));
102
107
  }
103
108
 
104
109
  /** LOW-1 residual closure (2026-10-27): async wrapper used by the worker
105
110
  * bootstrap (prompt-runtime Feature 2b). Identical contract to
106
111
  * {@link startChildBrokerClient}, plus: when the env token is ABSENT but the
107
- * socket/run/task identity env IS present (new parent on the stdin-handshake
108
- * default), read the single buildBrokerStdinLine payload off stdin (bounded,
109
- * never throws) and merge the per-task token into a COPY of the env before
110
- * connecting. Ordering matrix:
111
- * - new parent + new worker → token via stdin (env stays secret-free);
112
- * - old parent → new worker → env token present → env route, stdin untouched;
113
- * - new parent + PI_CREW_BROKER_STDIN=0 → env token present → env route;
114
- * - payload missing/invalid/wrong-run/socket-mismatch → creds-less no-op
115
- * handle (same as today), never a throw.
112
+ * socket/run/task identity env IS present (gate-ON parent on the fd3 route —
113
+ * FIX-1 re-writes the flag into the child's built.env, without which this
114
+ * fallback was dead-wired), read the single buildBrokerStdinLine payload off
115
+ * the DEDICATED fd3 handshake pipe (bounded, never throws) and merge the
116
+ * per-task token into a COPY of the env before connecting. FD-3
117
+ * (2026-10-28, root-cause fix): pi@1.0.4 readPipedStdin consumes ALL piped
118
+ * fd0 bytes as PROMPT CONTENT in every mode except --mode rpc — the old
119
+ * fd0 handshake raced it three ways (token JSON leaked into the worker
120
+ * prompt; creds-less silence; pause() deadlock → SIGTERM exit 0 with ~0
121
+ * stdout — the 0.11.46/47 live incident), so the spawner now writes the
122
+ * payload to a dedicated stdio[3] pipe and this reader reads fd 3, never
123
+ * fd0. Ordering matrix (post FIX-1, 2026-10-28; DEFAULT-ON flip same day —
124
+ * the gate is ON unless the exact value '0' opts out, and the spawner writes
125
+ * flag + token into built.env for every brokered spawn; only the headless
126
+ * JSON compose strips the token):
127
+ * - default parent (gate ON — unset/'1'/any non-'0') + headless JSON worker
128
+ * → flag set, token ABSENT from env → token via fd3 (env stays secret-free);
129
+ * - gate-ON parent + RPC / surface worker → flag set, token PRESENT in env
130
+ * → env route, fd3 untouched (their stdin is the protocol channel/TTY);
131
+ * - opt-out parent (PI_CREW_BROKER_STDIN=0) → no flag write, env token
132
+ * present → env route (and even if the token were missing, the mirror
133
+ * gate's '0' check disarms the fd3 read);
134
+ * - payload missing/invalid/wrong-run/socket-mismatch/unreadable fd3 →
135
+ * creds-less no-op handle (same as today), never a throw.
116
136
  * The sync {@link startChildBrokerClient} stays untouched (env-only) so every
117
137
  * existing caller/test keeps its exact behavior. */
118
138
  export async function startChildBrokerClientAsync(options: StartChildBrokerClientOptions = {}): Promise<ChildBrokerClientHandle> {
119
139
  let env = options.env ?? process.env;
120
- const socketPath = env.PI_CREW_BROKER_SOCKET;
121
- const runId = env.PI_CREW_BROKER_RUN_ID;
122
- const taskId = env.PI_CREW_BROKER_TASK_ID;
123
- if (socketPath && runId && taskId && !env.PI_CREW_BROKER_TOKEN && resolveBrokerStdinFallbackEnabled() && !argvIndicatesRpcMode()) {
140
+ const socketPath = getCrewEnv("PI_CREW_BROKER_SOCKET", env);
141
+ const runId = getCrewEnv("PI_CREW_BROKER_RUN_ID", env);
142
+ const taskId = getCrewEnv("PI_CREW_BROKER_TASK_ID", env);
143
+ if (
144
+ socketPath &&
145
+ runId &&
146
+ taskId &&
147
+ !getCrewEnv("PI_CREW_BROKER_TOKEN", env) &&
148
+ resolveBrokerStdinFallbackEnabled(env) &&
149
+ !argvIndicatesRpcMode()
150
+ ) {
124
151
  try {
125
- const raw = await (options.readStdinLine ?? readStdinFirstLine)();
152
+ const raw = await (options.readHandshakeLine ?? (() => readFirstLineFromFd(BROKER_HANDSHAKE_FD)))();
126
153
  const payload = raw ? parseStdinBrokerPayload(raw, runId) : undefined;
127
154
  // socketPath cross-check: the payload must describe the SAME socket the
128
155
  // parent announced via env (both are parent-controlled, but a mismatch
@@ -139,11 +166,22 @@ export async function startChildBrokerClientAsync(options: StartChildBrokerClien
139
166
  }
140
167
 
141
168
  /** Worker-side mirror of the parent gate (child-pi-spawn
142
- * .resolveBrokerStdinHandshakeEnabled). **OPT-IN since v0.11.48** (live
143
- * battery caught the reader path killing real headless workers — see the
144
- * parent gate's doc block): the default is now OFF, so a new parent that set
145
- * the env token travels the legacy env route. PI_CREW_BROKER_STDIN=1 opts
146
- * BOTH sides into the stdin route. */
147
- function resolveBrokerStdinFallbackEnabled(): boolean {
148
- return getCrewEnv("PI_CREW_BROKER_STDIN") === "1";
169
+ * .resolveBrokerStdinHandshakeEnabled). **DEFAULT-ON since 2026-10-28** (was
170
+ * OPT-IN v0.11.48–0.11.49, after the live battery caught the original fd0
171
+ * reader path killing real headless workers — see the parent gate's doc
172
+ * block; the v0.11.49 fd3 transport removed that race): the default is ON,
173
+ * so an unset flag arms the fd3 fallback whenever the identity env is
174
+ * present and the env token is absent. PI_CREW_BROKER_STDIN=0 opts BOTH
175
+ * sides out (the spawner then writes the env token instead — legacy env
176
+ * route). FIX-1 (2026-10-28): the spawner re-writes the flag "1" into the
177
+ * child's built.env ("child-write" registry kind), so a gate-ON parent
178
+ * reaches pre-flip workers too (their mirror still reads === "1") and the
179
+ * child route is pinned deterministically.
180
+ * Seam (2026-10-28): the gate reads the RESOLVED env — the same source as
181
+ * the identity vars above (`options.env ?? process.env`, threaded through
182
+ * getCrewEnv's env param) — so spawn-env delivery (FIX-1 writes the flag
183
+ * into the child's built.env) is pinnable in tests via options.env without
184
+ * mutating process.env. */
185
+ function resolveBrokerStdinFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
186
+ return getCrewEnv("PI_CREW_BROKER_STDIN", env) !== "0";
149
187
  }
@@ -12,9 +12,29 @@
12
12
  *
13
13
  * Pure protocol pieces live here (not in background-runner.ts, which runs
14
14
  * `await main()` at module scope) so tests can import them without booting
15
- * the runner.
15
+ * the runner. buildBrokerStdinLine additionally carries ONE LOUD oversize
16
+ * diagnostic (FIX-3, 2026-10-28) — stderr-only, never throws, never blocks
17
+ * the write.
18
+ *
19
+ * FD-3 TRANSPORT (2026-10-28, LOW-1 root-cause fix): brokered child-pi
20
+ * workers (real `pi` processes) CANNOT use fd0 for this handshake — pi@1.0.4
21
+ * `readPipedStdin()` (dist/main.js:52-65) consumes ALL piped stdin as PROMPT
22
+ * CONTENT in every mode except --mode rpc, so an fd0 handshake raced it
23
+ * three ways (token JSON merged into the prompt = secret leak; stream-ended
24
+ * creds-less silence; reader pause() before EOF → pi's await readPipedStdin()
25
+ * deadlock → SIGTERM exit 0 with ~0 stdout — the 0.11.46/47 live incident).
26
+ * The spawn side therefore adds a DEDICATED pipe as stdio entry 4 (fd 3) and
27
+ * writes the payload there (child-pi-spawn.ts/child-pi.ts); readFirstLineFromFd
28
+ * below is the worker-side reader for that pipe. fd0 stays "ignore"
29
+ * (pre-0.11.46 shape). The detached background RUNNER keeps the fd0 route —
30
+ * it is a plain node process whose stdin is dedicated to the handshake
31
+ * (readStdinFirstLine, unchanged); only the child-pi (pi binary) path needed
32
+ * the dedicated fd.
16
33
  */
17
34
 
35
+ import * as fs from "node:fs";
36
+ import { logInternalError } from "../../utils/internal-error.ts";
37
+
18
38
  /** The one-line payload async-runner writes to the runner's stdin carrying
19
39
  * PER-TASK compound tokens. v2 (ADR-0 2026-08-17 item 6): wait.* accepts
20
40
  * task-scoped (compound) tokens ONLY — the legacy bare-runId token
@@ -56,27 +76,62 @@ export function parseStdinBrokerPayload(raw: string, expectedRunId: string): Std
56
76
  * the detached background runner AND the main child-pi spawn path — share the
57
77
  * pure protocol piece and cannot drift from the reader parser below (a
58
78
  * writer/reader drift silently breaks coordination). Compound (runId+taskId)
59
- * tokens only: wait.* rejects bare-runId tokens (ADR-0 item 6). */
79
+ * tokens only: wait.* rejects bare-runId tokens (ADR-0 item 6).
80
+ *
81
+ * FIX-3 (2026-10-28): LOUD oversize diagnostic at the single construction
82
+ * point shared by BOTH writers. The worker-side reader (readStdinFirstLine)
83
+ * aborts once its buffer passes 64 KiB without a newline — a payload built
84
+ * from ~2.5k tasks (~131 KiB observed live) silently loses EVERY task's
85
+ * broker creds (ask/message/mailbox/steer degrade to file-poll) with zero
86
+ * trace in production. Warn at 60 KiB (below the reader cap, far above any
87
+ * sane task fanout) — NOT gated on PI_CREW_DEBUG: this is a "your creds
88
+ * will be lost" operational alarm, not a debug trace. The line is still
89
+ * returned (and written by the caller) — never blocked. */
90
+ export const BROKER_STDIN_LINE_WARN_BYTES = 60 * 1024;
91
+
60
92
  export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
61
- return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
93
+ const line = `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
94
+ if (line.length > BROKER_STDIN_LINE_WARN_BYTES) {
95
+ logInternalError(
96
+ "stdin-handshake.oversize-payload",
97
+ new Error(
98
+ `broker stdin handshake line is ${line.length} bytes — the worker reader aborts past 64 KiB, so EVERY task loses its broker creds (ask/message/mailbox/steer fall back to file-poll)`,
99
+ ),
100
+ `${Object.keys(tasks).length} tasks on run ${runId}; shrink the task fanout or split the run`,
101
+ "warn",
102
+ );
103
+ }
104
+ return line;
62
105
  }
63
106
 
64
- /** Read the FIRST stdin line with a hard cap. Resolves undefined for: no
65
- * stdin / TTY / silence past the timeout / oversize / error. Never throws,
66
- * never destroys stdin (leaves it paused for the rest of boot). */
67
- export function readStdinFirstLine(timeoutMs = 2000): Promise<string | undefined> {
107
+ /** Dedicated handshake fd for brokered child-pi workers: spawn stdio entry
108
+ * 4 (spawnOptions.stdio = ["ignore","pipe","pipe","pipe"]). fd0 stays
109
+ * "ignore" — the pre-0.11.46 shape — so pi@1.0.4 readPipedStdin can never
110
+ * race or merge the credential into the worker prompt (LOW-1 root cause,
111
+ * see the module docblock). */
112
+ export const BROKER_HANDSHAKE_FD = 3;
113
+
114
+ /** Read the FIRST line off a byte stream with a hard cap. Shared core of
115
+ * BOTH transports (fd0 for the detached runner, fd3 for child-pi workers).
116
+ * Resolves undefined for: silence past the timeout / oversize / stream
117
+ * error / empty EOF. Never throws, never destroys the stream (pauses it
118
+ * only after a first line was consumed — probe-verified 2026-10-28:
119
+ * destroy()/closeSync on an fs fd-stream is fragile on the error path
120
+ * (SIGABRT on EINVAL with autoClose) and a plain pause releases the loop on
121
+ * the payload/EOF shapes; the only shape that outlives the timeout is an
122
+ * open silent pipe, which a same-version parent can never produce — the
123
+ * spawner writes payload + end() synchronously in the spawn tick). */
124
+ function readFirstLineFromStream(
125
+ stream: Pick<NodeJS.ReadableStream, "on" | "removeListener" | "pause">,
126
+ timeoutMs: number,
127
+ ): Promise<string | undefined> {
68
128
  return new Promise((resolve) => {
69
- const stdin = process.stdin;
70
- if (!stdin || stdin.isTTY || stdin.destroyed || !stdin.readable) {
71
- resolve(undefined);
72
- return;
73
- }
74
129
  let settled = false;
75
130
  const finish = (value: string | undefined): void => {
76
131
  if (settled) return;
77
132
  settled = true;
78
133
  clearTimeout(timer);
79
- stdin.removeListener("data", onData);
134
+ stream.removeListener("data", onData);
80
135
  resolve(value);
81
136
  };
82
137
  const timer = setTimeout(() => finish(undefined), timeoutMs);
@@ -85,14 +140,55 @@ export function readStdinFirstLine(timeoutMs = 2000): Promise<string | undefined
85
140
  buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
86
141
  const nl = buf.indexOf("\n");
87
142
  if (nl >= 0) {
88
- stdin.pause();
143
+ stream.pause();
89
144
  finish(buf.slice(0, nl));
90
145
  } else if (buf.length > 64 * 1024) {
91
146
  finish(undefined);
92
147
  }
93
148
  };
94
- stdin.on("data", onData);
95
- stdin.on("end", () => finish(buf.length > 0 ? buf : undefined));
96
- stdin.on("error", () => finish(undefined));
149
+ stream.on("data", onData);
150
+ stream.on("end", () => finish(buf.length > 0 ? buf : undefined));
151
+ stream.on("error", () => finish(undefined));
152
+ });
153
+ }
154
+
155
+ /** Read the FIRST stdin line with a hard cap. Resolves undefined for: no
156
+ * stdin / TTY / silence past the timeout / oversize / error. Never throws,
157
+ * never destroys stdin (leaves it paused for the rest of boot). Detached
158
+ * background-runner transport ONLY (see module docblock — pi workers read
159
+ * the dedicated fd3 pipe via readFirstLineFromFd). */
160
+ export function readStdinFirstLine(timeoutMs = 2000): Promise<string | undefined> {
161
+ const stdin = process.stdin;
162
+ if (!stdin || stdin.isTTY || stdin.destroyed || !stdin.readable) {
163
+ return Promise.resolve(undefined);
164
+ }
165
+ return readFirstLineFromStream(stdin, timeoutMs);
166
+ }
167
+
168
+ /** Read the FIRST line from a raw file descriptor — the worker-side reader
169
+ * of the DEDICATED fd3 handshake pipe (BROKER_HANDSHAKE_FD). Same bounded
170
+ * contract as readStdinFirstLine (2s default timeout, 64 KiB cap, never
171
+ * throws): a missing/closed/unreadable fd (old-parent skew: gate flag set
172
+ * but no 4th stdio entry), an EINVAL read on a non-readable fd, silence
173
+ * past the timeout, oversize, or error all resolve undefined — the caller
174
+ * proceeds creds-less exactly like a broker-less spawn. win32: immediate
175
+ * undefined (extra-fd spawn plumbing differs there and the broker is
176
+ * auto-disabled on Windows anyway). The fd is never closed by this reader
177
+ * (autoClose: false — it belongs to the spawn stdio table, and closing an
178
+ * fd under an active stream poll is what crashes Node's fs streams). */
179
+ export function readFirstLineFromFd(fd: number, timeoutMs = 2000): Promise<string | undefined> {
180
+ if (process.platform === "win32") {
181
+ return Promise.resolve(undefined);
182
+ }
183
+ return new Promise((resolve) => {
184
+ let stream: fs.ReadStream;
185
+ try {
186
+ // fd option ⇒ the path argument is only a label, never opened.
187
+ stream = fs.createReadStream("", { fd, autoClose: false });
188
+ } catch {
189
+ resolve(undefined);
190
+ return;
191
+ }
192
+ readFirstLineFromStream(stream, timeoutMs).then(resolve, () => resolve(undefined));
97
193
  });
98
194
  }
@@ -46,24 +46,48 @@ import {
46
46
  // NOT set this (their sessions persist and benefit from long-lived cache).
47
47
  export const PI_CACHE_RETENTION_ENV = "PI_CACHE_RETENTION";
48
48
 
49
- /** LOW-1 residual closure (2026-10-27, DISABLED-DEFAULT 2026-10-28 v0.11.48):
49
+ /** LOW-1 residual closure (2026-10-27, DISABLED-DEFAULT v0.11.48 2026-10-28,
50
+ * DEFAULT-ON 2026-10-28):
50
51
  * when the inter-pi broker credentials are present, the per-task compound TOKEN
51
- * can travel over the spawn stdin pipe (one `buildBrokerStdinLine` payload,
52
- * heap → pipe → heap — same shape as the F4 background-runner handshake)
53
- * instead of the child env. **EXPERIMENTAL / OPT-IN since v0.11.48**: the
54
- * worker-side stdin reader path (startChildBrokerClientAsync →
55
- * readStdinFirstLine) was caught by the live battery (real-test-pi-crew T7)
56
- * making REAL headless JSON workers exit 0 silently with zero stdout — only
57
- * when the reader activates (env broker identity + no env token + gate ON).
58
- * Unit tests missed it because they inject the `readStdinLine` seam instead of
59
- * running process.stdin in a real pi process. Until that is root-caused, the
60
- * default is OFF (legacy env route, bit-for-bit pre-0.11.46 behavior); set
61
- * PI_CREW_BROKER_STDIN=1 to opt the parent into the stdin route. Only the
62
- * headless JSON spawn branch consumes the payload: the RPC branch (stdin is
63
- * the JSONL protocol channel) and the surface TUI panes keep the env route,
64
- * so their env is untouched either way. */
52
+ * can travel over a DEDICATED fd3 spawn pipe (one `buildBrokerStdinLine`
53
+ * payload, heap → stdio[3] → heap — fd0 stays "ignore") instead of the child
54
+ * env. **DEFAULT-ON since 2026-10-28 (was OPT-IN v0.11.48–0.11.49)**: the first attempt (v0.11.46/47)
55
+ * rode fd0 and was caught by the live battery (real-test-pi-crew T7) making
56
+ * REAL headless JSON workers exit 0 silently with zero stdout. Root cause
57
+ * (2026-10-28): pi@1.0.4 `readPipedStdin()` (dist/main.js:52-65) consumes ALL
58
+ * piped stdin as PROMPT CONTENT in every mode except --mode rpc — the fd0
59
+ * handshake raced it three ways (token JSON merged into the prompt = secret
60
+ * leak; stream-ended creds-less silence; reader pause() before EOF → pi's
61
+ * await readPipedStdin() deadlock → SIGTERM exit 0 with takeOverStdout
62
+ * buffering stdout to ~0 bytes). The fix moves the handshake to fd3
63
+ * (stdio entry 4); fd0 returns to "ignore" (pre-0.11.46 shape), killing every
64
+ * race ordering. Unit tests missed the original because they inject the
65
+ * `readHandshakeLine` seam instead of running the fd reader in a real pi
66
+ * process. DEFAULT-ON FLIP (2026-10-28): with the fd3 transport proven live
67
+ * (v0.11.49), the gate flipped from opt-in to DEFAULT-ON — only exact
68
+ * PI_CREW_BROKER_STDIN=0 opts the parent out (legacy env route bit-for-bit
69
+ * pre-0.11.46); unset/'1'/any other value takes the fd3 route.
70
+ *
71
+ * FIX-1 dead-wire repair (2026-10-28): when this gate is ON and broker
72
+ * credentials exist, prepareSpawnContext (1) WRITES PI_CREW_BROKER_STDIN="1"
73
+ * into built.env — the registry classifies the var "child-write" so ambient
74
+ * forwarding never carries it, and the worker-side mirror gate
75
+ * (crew-broker-child.resolveBrokerStdinFallbackEnabled) reads ITS OWN
76
+ * process.env; before the fix the flag never reached the child, so the
77
+ * fallback NEVER activated and every opt-in spawn stayed creds-less;
78
+ * (2) puts the TOKEN in built.env for BOTH branches — the RPC branch (stdin
79
+ * is the JSONL protocol channel) and surface TUI panes compose WITHOUT the
80
+ * payload, so only that env write hands them the credential (a token in
81
+ * their env also disarms the worker fd3 fallback — safe by design).
82
+ * Only the headless JSON branch composes WITH ctx.brokerStdinPayload, and
83
+ * buildFinalChildPiSpawnOptions strips the token from the FINAL env there
84
+ * (defense-in-depth) — on that branch alone the token rides the fd3 pipe.
85
+ * Gate OFF ('0'): no flag, no payload, token via env — bit-for-bit legacy.
86
+ * The FIX-1 "1" write stays even under the DEFAULT-ON flip: it keeps a NEW
87
+ * default-ON parent compatible with PRE-FLIP workers (their mirror gate still
88
+ * reads === "1"), and pins the child's route deterministically. */
65
89
  export function resolveBrokerStdinHandshakeEnabled(): boolean {
66
- return getCrewEnv("PI_CREW_BROKER_STDIN") === "1";
90
+ return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
67
91
  }
68
92
 
69
93
  // ── Env allowlist (base set always passed to children) ──────────────────
@@ -310,15 +334,23 @@ export function buildFinalChildPiSpawnOptions(
310
334
  if (workerCacheSplit) {
311
335
  spawnOptions.env[PI_CACHE_RETENTION_ENV] = "short";
312
336
  }
313
- // (d) LOW-1 closure: when the caller will hand the broker token to the child
314
- // over the stdin pipe (headless JSON branch — it passes the payload it got
315
- // from prepareSpawnContext), switch stdin from "ignore" to "pipe" and strip
316
- // the secret from the env as defense-in-depth (prepareSpawnContext already
317
- // omits it; the delete guards direct builtEnv constructions in tests/callers
318
- // that still carry it). RPC branch / surface workerEnv snapshots call
319
- // WITHOUT this param → env keeps the token and nothing else changes.
337
+ // (d) LOW-1 closure → FD-3 (2026-10-28): when the caller will hand the
338
+ // broker token to the headless JSON child over the DEDICATED fd3 pipe
339
+ // (it passes the payload it got from prepareSpawnContext), add the 4th
340
+ // stdio entry ("pipe") and strip the secret from the env as
341
+ // defense-in-depth (prepareSpawnContext already omits it; the delete
342
+ // guards direct builtEnv constructions in tests/callers that still
343
+ // carry it). fd0 stays "ignore" — the pre-0.11.46 shape: pi@1.0.4
344
+ // readPipedStdin (dist/main.js:52-65) consumes ALL piped fd0 bytes as
345
+ // PROMPT CONTENT in every mode except --mode rpc, so the earlier fd0
346
+ // handshake raced it three ways (token JSON merged into the prompt =
347
+ // secret leak; creds-less silence; pause() → readPipedStdin deadlock →
348
+ // SIGTERM exit 0 with ~0 stdout — the 0.11.46/47 live incident).
349
+ // fd0=ignore kills every ordering: fd3 is a dedicated pipe pi never
350
+ // touches. RPC branch / surface workerEnv snapshots call WITHOUT this
351
+ // param → env keeps the token, 3-entry stdio, nothing else changes.
320
352
  if (stdinBrokerPayload !== undefined) {
321
- spawnOptions.stdio = ["pipe", "pipe", "pipe"];
353
+ spawnOptions.stdio = ["ignore", "pipe", "pipe", "pipe"];
322
354
  delete spawnOptions.env.PI_CREW_BROKER_TOKEN;
323
355
  }
324
356
  // (e) Return the composed SpawnOptions for spawn(...).
@@ -345,10 +377,14 @@ export interface SpawnContext {
345
377
  /** v0.11.31: inference-broker worker key that owns this spawn's token (cleanup/revoke bookkeeping). */
346
378
  inferenceWorkerKey?: string;
347
379
  /** LOW-1 closure: one-line broker credential payload to write to the
348
- * child's stdin right after spawn (headless JSON branch ONLY — the RPC
349
- * branch owns stdin for its JSONL protocol and surface panes are TTY).
350
- * Present ⇒ PI_CREW_BROKER_TOKEN is NOT in builtEnv (token travels
351
- * heap → pipe → heap instead). Undefined ⇒ legacy env route. */
380
+ * child's DEDICATED fd3 pipe (stdio entry 4) right after spawn — headless
381
+ * JSON branch ONLY (the RPC branch owns stdin for its JSONL protocol and
382
+ * surface panes are TTY; fd0 stays "ignore" for every branch). Present ⇒
383
+ * the headless compose adds the 4th stdio entry and strips
384
+ * PI_CREW_BROKER_TOKEN from the FINAL child env
385
+ * (buildFinalChildPiSpawnOptions); builtEnv still carries the token for
386
+ * the RPC/surface call sites, which compose WITHOUT this payload and keep
387
+ * the env route. Undefined ⇒ env route everywhere, 3-entry stdio. */
352
388
  brokerStdinPayload?: string;
353
389
  }
354
390
 
@@ -459,22 +495,43 @@ export function prepareSpawnContext(
459
495
  // safe under assertOnlyControlEnvKeys). Only when the parent broker issued
460
496
  // credentials for this run — i.e. the broker is enabled AND this run is
461
497
  // eligible. NEVER persisted to disk.
462
- // LOW-1 closure (OPT-IN since v0.11.48): when PI_CREW_BROKER_STDIN=1 the
463
- // TOKEN leaves the env entirely — it rides the
464
- // spawn stdin pipe as a single buildBrokerStdinLine payload (ctx
465
- // .brokerStdinPayload; the headless JSON branch pipes stdin and writes it
466
- // right after spawn). SOCKET/RUN_ID/TASK_ID stay in env (non-secret, needed
467
- // by the worker bootstrap to bind and validate the stdin payload). Default
468
- // (gate off, or a legacy shape without runId/taskId to
469
- // bind the payload to) keeps the env route bit-for-bit.
498
+ // LOW-1 closure (OPT-IN v0.11.48–0.11.49, DEFAULT-ON since 2026-10-28;
499
+ // FIX-1 dead-wire repair 2026-10-28; FD-3 transport 2026-10-28): when the
500
+ // gate is ON (default — only exact PI_CREW_BROKER_STDIN=0 opts out) the
501
+ // headless JSON spawn branch delivers the token over a DEDICATED fd3 pipe (4th
502
+ // stdio entry; ctx.brokerStdinPayload — fd0 stays "ignore") as a single
503
+ // buildBrokerStdinLine payload, and buildFinalChildPiSpawnOptions strips
504
+ // it from the FINAL env there. The token still lands in built.env for
505
+ // every brokered spawn — the RPC/surface call sites compose without the
506
+ // payload and need the env credential. SOCKET/RUN_ID/TASK_ID stay in env
507
+ // (non-secret, needed by the worker bootstrap to bind and validate the
508
+ // fd3 payload). Opt-out (gate '0') keeps the env route bit-for-bit.
470
509
  if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
471
510
  built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
472
- if (resolveBrokerStdinHandshakeEnabled() && input.runId && input.agentId) {
511
+ const brokerStdinRoute = resolveBrokerStdinHandshakeEnabled();
512
+ if (brokerStdinRoute) {
513
+ // FIX-1: the worker-side mirror gate reads PI_CREW_BROKER_STDIN from
514
+ // the CHILD's own process.env; the registry classifies the var
515
+ // "child-write" (ambient forwarding never carries it), so the spawner
516
+ // MUST write it here — before the fix the flag never reached the
517
+ // child and the stdin fallback NEVER activated (creds-less NOOP).
518
+ // Kept under the DEFAULT-ON flip for pre-flip-worker compatibility
519
+ // (their mirror gate still reads === "1").
520
+ built.env.PI_CREW_BROKER_STDIN = "1";
521
+ }
522
+ // FIX-1: the token is written into built.env for BOTH branches. The RPC
523
+ // branch (child-pi-rpc.ts) and surface TUI panes (child-pi.ts
524
+ // trySurfaceBranch) compose WITHOUT the payload — their stdin is the
525
+ // JSONL protocol channel / a TTY — so only this env write hands them the
526
+ // credential (a token in their env also disarms the worker fd3 fallback
527
+ // — safe). The headless JSON branch composes WITH the payload below, and
528
+ // buildFinalChildPiSpawnOptions strips the token from the FINAL env
529
+ // there — on that branch alone the token rides the fd3 pipe.
530
+ built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
531
+ if (brokerStdinRoute && input.runId && input.agentId) {
473
532
  brokerStdinPayload = buildBrokerStdinLine(input.runId, input.brokerSpawn.socketPath, {
474
533
  [input.agentId]: input.brokerSpawn.token,
475
534
  });
476
- } else {
477
- built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
478
535
  }
479
536
  // runId/taskId are threaded unconditionally above (I5); nothing to add here.
480
537
  }