pi-crew 0.11.47 → 0.11.49

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.47",
3
+ "version": "0.11.49",
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,11 @@ 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
+
200
205
  ## Core principle: disk ≠ live Pi
201
206
 
202
207
  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 — read by the HOST spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route, then RE-WRITTEN by that spawner into every brokered child's built.env ('1') so the worker-side mirror gate (crew-broker-child .resolveBrokerStdinFallbackEnabled, which reads its OWN process.env) actually sees it — 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: unset/non-'0' (default) 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; '0' rolls back to the legacy env route (token in built.env, stdin stays 'ignore') on BOTH sides. 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, 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 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; unset/any-other-value (default) keeps 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;
@@ -182,7 +182,7 @@ export function parentAlive(pid: number, expectedStartTime: string | undefined,
182
182
  // workers at the first 5s tick.
183
183
  readerThrew = true;
184
184
  }
185
- if (readerThrew) return expectedStartTime ? false : true;
185
+ if (readerThrew) return !expectedStartTime;
186
186
  // Reader returned "no entry" (default /proc reader ENOENT = dead on
187
187
  // Linux) — dead-parent detection stays intact.
188
188
  if (stat === undefined) return false;
@@ -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,56 @@ 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 (opt-in 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 — the gate is OPT-IN and the
124
+ * spawner writes flag + token into built.env for every brokered spawn; only
125
+ * the headless JSON compose strips the token):
126
+ * - opt-in parent (PI_CREW_BROKER_STDIN=1) + headless JSON worker → flag
127
+ * set, token ABSENT from env → token via fd3 (env stays secret-free);
128
+ * - opt-in parent + RPC / surface worker → flag set, token PRESENT in env
129
+ * → env route, fd3 untouched (their stdin is the protocol channel/TTY);
130
+ * - legacy parent (gate off/unset) → env token present → env route;
131
+ * - payload missing/invalid/wrong-run/socket-mismatch/unreadable fd3 →
132
+ * creds-less no-op handle (same as today), never a throw.
116
133
  * The sync {@link startChildBrokerClient} stays untouched (env-only) so every
117
134
  * existing caller/test keeps its exact behavior. */
118
135
  export async function startChildBrokerClientAsync(options: StartChildBrokerClientOptions = {}): Promise<ChildBrokerClientHandle> {
119
136
  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()) {
137
+ const socketPath = getCrewEnv("PI_CREW_BROKER_SOCKET", env);
138
+ const runId = getCrewEnv("PI_CREW_BROKER_RUN_ID", env);
139
+ const taskId = getCrewEnv("PI_CREW_BROKER_TASK_ID", env);
140
+ if (
141
+ socketPath &&
142
+ runId &&
143
+ taskId &&
144
+ !getCrewEnv("PI_CREW_BROKER_TOKEN", env) &&
145
+ resolveBrokerStdinFallbackEnabled(env) &&
146
+ !argvIndicatesRpcMode()
147
+ ) {
124
148
  try {
125
- const raw = await (options.readStdinLine ?? readStdinFirstLine)();
149
+ const raw = await (options.readHandshakeLine ?? (() => readFirstLineFromFd(BROKER_HANDSHAKE_FD)))();
126
150
  const payload = raw ? parseStdinBrokerPayload(raw, runId) : undefined;
127
151
  // socketPath cross-check: the payload must describe the SAME socket the
128
152
  // parent announced via env (both are parent-controlled, but a mismatch
@@ -139,8 +163,19 @@ export async function startChildBrokerClientAsync(options: StartChildBrokerClien
139
163
  }
140
164
 
141
165
  /** Worker-side mirror of the parent gate (child-pi-spawn
142
- * .resolveBrokerStdinHandshakeEnabled): PI_CREW_BROKER_STDIN=0 keeps the
143
- * fallback OFF so a forced rollback is env-only on both sides. Default ON. */
144
- function resolveBrokerStdinFallbackEnabled(): boolean {
145
- return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
166
+ * .resolveBrokerStdinHandshakeEnabled). **OPT-IN since v0.11.48** (live
167
+ * battery caught the reader path killing real headless workers — see the
168
+ * parent gate's doc block): the default is OFF, so a legacy parent that set
169
+ * the env token travels the legacy env route. PI_CREW_BROKER_STDIN=1 opts
170
+ * BOTH sides into the stdin route — FIX-1 (2026-10-28): the spawner now
171
+ * re-writes the flag into the child's built.env ("child-write" registry
172
+ * kind), so an opt-in parent actually reaches this mirror gate (previously
173
+ * the flag died with the ambient-env strip and this fallback was dead-wired).
174
+ * Seam (2026-10-28): the gate reads the RESOLVED env — the same source as
175
+ * the identity vars above (`options.env ?? process.env`, threaded through
176
+ * getCrewEnv's env param) — so spawn-env delivery (FIX-1 writes the flag
177
+ * into the child's built.env) is pinnable in tests via options.env without
178
+ * mutating process.env. */
179
+ function resolveBrokerStdinFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
180
+ return getCrewEnv("PI_CREW_BROKER_STDIN", env) === "1";
146
181
  }
@@ -43,14 +43,14 @@ import { loadRunManifestById, saveRunTasks } from "../../state/stores/state-stor
43
43
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
44
44
  import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
45
45
  import { resolveCrewMaxDepth } from "../model/pi-args.ts";
46
- import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
46
+ import type { NestedSlotBudget } from "../scheduling/nested-slots.ts";
47
47
  import { evaluateDelegateAdmission } from "../spawn-policy.ts";
48
- import { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
49
- import { type DelegateEventTarget, type DelegateEventType } from "./delegate/delegate-event.ts";
48
+ import type { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
49
+ import type { DelegateEventTarget, DelegateEventType } from "./delegate/delegate-event.ts";
50
50
  import { delegateTerminalEvent, finalizeDelegateResult } from "./delegate/delegate-finalize.ts";
51
51
  import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
52
- import { type CrewBrokerOptions, type ServerConnection } from "./protocol/connection-state.ts";
53
- import { type LockBusyOutcome } from "./protocol/lock-busy.ts";
52
+ import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
53
+ import type { LockBusyOutcome } from "./protocol/lock-busy.ts";
54
54
  import { parseDelegateRequestParams } from "./protocol/request-parsers.ts";
55
55
 
56
56
  /** Options slice the delegate handler reads off the broker's RESOLVED
@@ -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,17 +46,42 @@ 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): when the inter-pi broker credentials
50
- * are present, the per-task compound TOKEN travels over the spawn stdin pipe
51
- * (one `buildBrokerStdinLine` payload, heap → pipe → heap — same shape as the
52
- * F4 background-runner handshake) instead of the child env. Set
53
- * PI_CREW_BROKER_STDIN=0 to roll back to the legacy env route (token in
54
- * built.env, stdin stays "ignore"). Default ON. Only the headless JSON spawn
55
- * branch consumes the payload: the RPC branch (stdin is the JSONL protocol
56
- * channel) and the surface TUI panes keep the env route, so their env is
57
- * untouched. */
49
+ /** LOW-1 residual closure (2026-10-27, DISABLED-DEFAULT 2026-10-28 v0.11.48):
50
+ * when the inter-pi broker credentials are present, the per-task compound TOKEN
51
+ * can travel over a DEDICATED fd3 spawn pipe (one `buildBrokerStdinLine`
52
+ * payload, heap → stdio[3] → heap — fd0 stays "ignore") instead of the child
53
+ * env. **EXPERIMENTAL / OPT-IN since v0.11.48**: the first attempt (v0.11.46/47)
54
+ * rode fd0 and was caught by the live battery (real-test-pi-crew T7) making
55
+ * REAL headless JSON workers exit 0 silently with zero stdout. Root cause
56
+ * (2026-10-28): pi@1.0.4 `readPipedStdin()` (dist/main.js:52-65) consumes ALL
57
+ * piped stdin as PROMPT CONTENT in every mode except --mode rpc — the fd0
58
+ * handshake raced it three ways (token JSON merged into the prompt = secret
59
+ * leak; stream-ended creds-less silence; reader pause() before EOF → pi's
60
+ * await readPipedStdin() deadlock → SIGTERM exit 0 with takeOverStdout
61
+ * buffering stdout to ~0 bytes). The fix moves the handshake to fd3
62
+ * (stdio entry 4); fd0 returns to "ignore" (pre-0.11.46 shape), killing every
63
+ * race ordering. Unit tests missed the original because they inject the
64
+ * `readHandshakeLine` seam instead of running the fd reader in a real pi
65
+ * process. The gate stays OPT-IN (legacy env route bit-for-bit pre-0.11.46);
66
+ * set PI_CREW_BROKER_STDIN=1 to opt the parent into the fd3 route.
67
+ *
68
+ * FIX-1 dead-wire repair (2026-10-28): when this gate is ON and broker
69
+ * credentials exist, prepareSpawnContext (1) WRITES PI_CREW_BROKER_STDIN="1"
70
+ * into built.env — the registry classifies the var "child-write" so ambient
71
+ * forwarding never carries it, and the worker-side mirror gate
72
+ * (crew-broker-child.resolveBrokerStdinFallbackEnabled) reads ITS OWN
73
+ * process.env; before the fix the flag never reached the child, so the
74
+ * fallback NEVER activated and every opt-in spawn stayed creds-less;
75
+ * (2) puts the TOKEN in built.env for BOTH branches — the RPC branch (stdin
76
+ * is the JSONL protocol channel) and surface TUI panes compose WITHOUT the
77
+ * payload, so only that env write hands them the credential (a token in
78
+ * their env also disarms the worker fd3 fallback — safe by design).
79
+ * Only the headless JSON branch composes WITH ctx.brokerStdinPayload, and
80
+ * buildFinalChildPiSpawnOptions strips the token from the FINAL env there
81
+ * (defense-in-depth) — on that branch alone the token rides the fd3 pipe.
82
+ * Gate OFF: no flag, no payload, token via env — bit-for-bit legacy. */
58
83
  export function resolveBrokerStdinHandshakeEnabled(): boolean {
59
- return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
84
+ return getCrewEnv("PI_CREW_BROKER_STDIN") === "1";
60
85
  }
61
86
 
62
87
  // ── Env allowlist (base set always passed to children) ──────────────────
@@ -303,15 +328,23 @@ export function buildFinalChildPiSpawnOptions(
303
328
  if (workerCacheSplit) {
304
329
  spawnOptions.env[PI_CACHE_RETENTION_ENV] = "short";
305
330
  }
306
- // (d) LOW-1 closure: when the caller will hand the broker token to the child
307
- // over the stdin pipe (headless JSON branch — it passes the payload it got
308
- // from prepareSpawnContext), switch stdin from "ignore" to "pipe" and strip
309
- // the secret from the env as defense-in-depth (prepareSpawnContext already
310
- // omits it; the delete guards direct builtEnv constructions in tests/callers
311
- // that still carry it). RPC branch / surface workerEnv snapshots call
312
- // WITHOUT this param → env keeps the token and nothing else changes.
331
+ // (d) LOW-1 closure → FD-3 (2026-10-28): when the caller will hand the
332
+ // broker token to the headless JSON child over the DEDICATED fd3 pipe
333
+ // (it passes the payload it got from prepareSpawnContext), add the 4th
334
+ // stdio entry ("pipe") and strip the secret from the env as
335
+ // defense-in-depth (prepareSpawnContext already omits it; the delete
336
+ // guards direct builtEnv constructions in tests/callers that still
337
+ // carry it). fd0 stays "ignore" — the pre-0.11.46 shape: pi@1.0.4
338
+ // readPipedStdin (dist/main.js:52-65) consumes ALL piped fd0 bytes as
339
+ // PROMPT CONTENT in every mode except --mode rpc, so the earlier fd0
340
+ // handshake raced it three ways (token JSON merged into the prompt =
341
+ // secret leak; creds-less silence; pause() → readPipedStdin deadlock →
342
+ // SIGTERM exit 0 with ~0 stdout — the 0.11.46/47 live incident).
343
+ // fd0=ignore kills every ordering: fd3 is a dedicated pipe pi never
344
+ // touches. RPC branch / surface workerEnv snapshots call WITHOUT this
345
+ // param → env keeps the token, 3-entry stdio, nothing else changes.
313
346
  if (stdinBrokerPayload !== undefined) {
314
- spawnOptions.stdio = ["pipe", "pipe", "pipe"];
347
+ spawnOptions.stdio = ["ignore", "pipe", "pipe", "pipe"];
315
348
  delete spawnOptions.env.PI_CREW_BROKER_TOKEN;
316
349
  }
317
350
  // (e) Return the composed SpawnOptions for spawn(...).
@@ -338,10 +371,14 @@ export interface SpawnContext {
338
371
  /** v0.11.31: inference-broker worker key that owns this spawn's token (cleanup/revoke bookkeeping). */
339
372
  inferenceWorkerKey?: string;
340
373
  /** LOW-1 closure: one-line broker credential payload to write to the
341
- * child's stdin right after spawn (headless JSON branch ONLY — the RPC
342
- * branch owns stdin for its JSONL protocol and surface panes are TTY).
343
- * Present ⇒ PI_CREW_BROKER_TOKEN is NOT in builtEnv (token travels
344
- * heap → pipe → heap instead). Undefined ⇒ legacy env route. */
374
+ * child's DEDICATED fd3 pipe (stdio entry 4) right after spawn — headless
375
+ * JSON branch ONLY (the RPC branch owns stdin for its JSONL protocol and
376
+ * surface panes are TTY; fd0 stays "ignore" for every branch). Present ⇒
377
+ * the headless compose adds the 4th stdio entry and strips
378
+ * PI_CREW_BROKER_TOKEN from the FINAL child env
379
+ * (buildFinalChildPiSpawnOptions); builtEnv still carries the token for
380
+ * the RPC/surface call sites, which compose WITHOUT this payload and keep
381
+ * the env route. Undefined ⇒ env route everywhere, 3-entry stdio. */
345
382
  brokerStdinPayload?: string;
346
383
  }
347
384
 
@@ -452,21 +489,40 @@ export function prepareSpawnContext(
452
489
  // safe under assertOnlyControlEnvKeys). Only when the parent broker issued
453
490
  // credentials for this run — i.e. the broker is enabled AND this run is
454
491
  // eligible. NEVER persisted to disk.
455
- // LOW-1 closure: by default the TOKEN leaves the env entirely — it rides the
456
- // spawn stdin pipe as a single buildBrokerStdinLine payload (ctx
457
- // .brokerStdinPayload; the headless JSON branch pipes stdin and writes it
458
- // right after spawn). SOCKET/RUN_ID/TASK_ID stay in env (non-secret, needed
459
- // by the worker bootstrap to bind + validate the stdin payload). Rollback
460
- // gate PI_CREW_BROKER_STDIN=0 (or a legacy shape without runId/taskId to
461
- // bind the payload to) keeps the env route bit-for-bit.
492
+ // LOW-1 closure (OPT-IN since v0.11.48; FIX-1 dead-wire repair 2026-10-28;
493
+ // FD-3 transport 2026-10-28): when PI_CREW_BROKER_STDIN=1 the headless
494
+ // JSON spawn branch delivers the token over a DEDICATED fd3 pipe (4th
495
+ // stdio entry; ctx.brokerStdinPayload — fd0 stays "ignore") as a single
496
+ // buildBrokerStdinLine payload, and buildFinalChildPiSpawnOptions strips
497
+ // it from the FINAL env there. The token still lands in built.env for
498
+ // every brokered spawn — the RPC/surface call sites compose without the
499
+ // payload and need the env credential. SOCKET/RUN_ID/TASK_ID stay in env
500
+ // (non-secret, needed by the worker bootstrap to bind and validate the
501
+ // fd3 payload). Default (gate off) keeps the env route bit-for-bit.
462
502
  if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
463
503
  built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
464
- if (resolveBrokerStdinHandshakeEnabled() && input.runId && input.agentId) {
504
+ const brokerStdinRoute = resolveBrokerStdinHandshakeEnabled();
505
+ if (brokerStdinRoute) {
506
+ // FIX-1: the worker-side mirror gate reads PI_CREW_BROKER_STDIN from
507
+ // the CHILD's own process.env; the registry classifies the var
508
+ // "child-write" (ambient forwarding never carries it), so the spawner
509
+ // MUST write it here — before the fix the flag never reached the
510
+ // child and the stdin fallback NEVER activated (creds-less NOOP).
511
+ built.env.PI_CREW_BROKER_STDIN = "1";
512
+ }
513
+ // FIX-1: the token is written into built.env for BOTH branches. The RPC
514
+ // branch (child-pi-rpc.ts) and surface TUI panes (child-pi.ts
515
+ // trySurfaceBranch) compose WITHOUT the payload — their stdin is the
516
+ // JSONL protocol channel / a TTY — so only this env write hands them the
517
+ // credential (a token in their env also disarms the worker fd3 fallback
518
+ // — safe). The headless JSON branch composes WITH the payload below, and
519
+ // buildFinalChildPiSpawnOptions strips the token from the FINAL env
520
+ // there — on that branch alone the token rides the fd3 pipe.
521
+ built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
522
+ if (brokerStdinRoute && input.runId && input.agentId) {
465
523
  brokerStdinPayload = buildBrokerStdinLine(input.runId, input.brokerSpawn.socketPath, {
466
524
  [input.agentId]: input.brokerSpawn.token,
467
525
  });
468
- } else {
469
- built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
470
526
  }
471
527
  // runId/taskId are threaded unconditionally above (I5); nothing to add here.
472
528
  }