pi-crew 0.11.45 → 0.11.46

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.
@@ -47,22 +47,18 @@ import {
47
47
  } from "../../state/coordination/mailbox.ts";
48
48
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
49
49
  import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
50
- import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
50
+ import type { TeamRunManifest } from "../../state/types.ts";
51
51
  import { runEventBus } from "../../ui/run-event-bus.ts";
52
52
  import { logInternalError } from "../../utils/internal-error.ts";
53
53
  import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
54
54
  import { redactSecretString } from "../../utils/redaction.ts";
55
55
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
56
56
  import { getBrokerSocketPath, prepareBrokerSocketDir, removeStaleBrokerSocket } from "../../utils/socket-path.ts";
57
- import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
58
- import { resolveCrewMaxDepth } from "../model/pi-args.ts";
59
57
  import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
60
- import { evaluateDelegateAdmission } from "../spawn-policy.ts";
58
+ import { handleDelegateRequest } from "./crew-broker-delegate-request.ts";
61
59
  import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
62
60
  import { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
63
61
  import { type DelegateEventTarget, type DelegateEventType, recordDelegateEvent } from "./delegate/delegate-event.ts";
64
- import { delegateTerminalEvent, finalizeDelegateResult } from "./delegate/delegate-finalize.ts";
65
- import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
66
62
  import { fanoutMailboxMessage } from "./mailbox-observer/mailbox-fanout.ts";
67
63
  import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
68
64
  // Outbound-queue management + caps (wc-gate move — bodies in protocol/connection-state.ts)
@@ -81,7 +77,6 @@ import {
81
77
  BROKER_PROTOCOL,
82
78
  isHelloParams,
83
79
  isRequestObject,
84
- parseDelegateRequestParams,
85
80
  parseMsgSendParams,
86
81
  parseWaitRequestParams,
87
82
  safeStringify,
@@ -1456,295 +1451,23 @@ export class CrewBroker {
1456
1451
  recordDelegateEvent(manifest, type, taskId, data);
1457
1452
  }
1458
1453
 
1454
+ /** T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
1455
+ * grandchild spawn with durable mailbox delivery (WP-5 step 5). Handler
1456
+ * body moved to ./crew-broker-delegate-request.ts (wc-gate ITEM C,
1457
+ * 2026-10-27 — pure move, no behavior change; deps threaded via
1458
+ * DelegateRequestDeps). */
1459
1459
  private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1460
- // Auth: task-scoped token MANDATORY (ADR-5 pin iii) — same rule as wait.*.
1461
- if (!conn.runId || !conn.taskId) {
1462
- this.sendError(conn, id, "auth", "not authed");
1463
- return;
1464
- }
1465
- if (conn.role !== "worker" || conn.authMatchKind !== "compound") {
1466
- this.sendError(conn, id, "forbidden", "delegate requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID");
1467
- return;
1468
- }
1469
- // R7-H1 fix: parse params through the protocol-level parser (mirrors
1470
- // parseWaitRequestParams discipline) — non-integer/non-finite/negative/
1471
- // over-ceiling maxTurns/budgetTokens never reach admission or the
1472
- // budget reservation (durable-negative tokensSpent escape).
1473
- const parsed = parseDelegateRequestParams(params);
1474
- if (!parsed) {
1475
- this.sendError(conn, id, "bad-params", "delegate.request: invalid params");
1476
- return;
1477
- }
1478
- const requested = parsed;
1479
- const cwd = this.options.cwd;
1480
- if (!cwd) {
1481
- this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1482
- return;
1483
- }
1484
- let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
1485
- try {
1486
- const l = loadRunManifestById(cwd, conn.runId);
1487
- if (!l) {
1488
- this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1489
- return;
1490
- }
1491
- loaded = l;
1492
- } catch (err) {
1493
- this.sendError(conn, id, "no-manifest", (err as Error).message);
1494
- return;
1495
- }
1496
- // Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
1497
- // flip the DEFAULT is true, so reaching this branch means the user
1498
- // closed the surface via config — the message points back at the knob.
1499
- if (this.options.nestingEnabled !== true) {
1500
- this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
1501
- reason: "nesting-disabled",
1502
- policy: "nesting.enabled=false (user config; default is true since D8)",
1503
- });
1504
- this.sendError(
1505
- conn,
1506
- id,
1507
- "policy-disabled",
1508
- "delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
1509
- );
1510
- return;
1511
- }
1512
- const runId = conn.runId;
1513
- const parentTaskId = conn.taskId;
1514
- // S1#1 fix (security round 1): the subId is minted BEFORE admission so the
1515
- // grandchild token scopes to the SUBID (never the parent task key) and the
1516
- // nested slot is acquired INSIDE the same lock as the admission snapshot.
1517
- const subId = `gc-${randomUUID()}`;
1518
- this.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
1519
- // Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
1520
- // the RECORD under the run lock — never the worker's env/self-report.
1521
- // RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
1522
- const admissionAttempt = await this.runLockBusyRetry(loaded.manifest, () => {
1523
- const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1524
- if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
1525
- const task = fresh.tasks.find((t) => t.id === parentTaskId);
1526
- if (!task) {
1527
- this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "no-task" });
1528
- return { code: "no-task" as const, message: `task '${parentTaskId}' not found` };
1529
- }
1530
- if (task.status !== "running") {
1531
- this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
1532
- subId,
1533
- reason: "parent-not-running",
1534
- message: `task is ${task.status}`,
1535
- });
1536
- return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
1537
- }
1538
- // RR-012 F03: authoritative execution cwd = the parent task's cwd from
1539
- // this locked fresh read — ONE source for overlap check, shadow record
1540
- // and spawner input; broker cwd stays for manifest lookup only.
1541
- const executionCwd = task.cwd;
1542
- const catalog = this.options.modelCatalog?.();
1543
- // S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
1544
- const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
1545
- // ADR-5 §9: count OTHER in-flight executor-class tasks sharing the
1546
- // parent task's cwd (manifest record — single source of truth).
1547
- const overlapping = fresh.tasks.filter(
1548
- (t) =>
1549
- t.id !== parentTaskId &&
1550
- t.status === "running" &&
1551
- (t.role === "executor" || t.role === "test-engineer") &&
1552
- t.cwd === executionCwd,
1553
- ).length;
1554
- const decision = evaluateDelegateAdmission({
1555
- maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
1556
- parentTask: {
1557
- taskId: parentTaskId,
1558
- role: task.role,
1559
- ...(task.depth !== undefined ? { depth: task.depth } : {}),
1560
- ...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
1561
- },
1562
- slots: this.nestedSlotBudget().snapshot(),
1563
- requested,
1564
- ...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
1565
- // ADR-5 §12: the delegate surface is an escalation — trusted only by the
1566
- // explicit user opt-in threaded from config.nesting.enabled (sensitive).
1567
- untrusted: this.options.nestingTrustedEscalation !== true,
1568
- // M3-3 / R43-N1 SEED: the run-level token budget is the grant every
1569
- // task inherits when its own record carries no allocation (pre-fix
1570
- // state of EVERY task — nothing produced `tokensGranted`, so every
1571
- // budgeted delegate was denied and every unmetered one slipped by).
1572
- // `budgetUnlimited` opts out of enforcement entirely ⇒ no seed.
1573
- ...(fresh.manifest.budgetTotal !== undefined && fresh.manifest.budgetUnlimited !== true
1574
- ? { parentBudgetGrantTokens: fresh.manifest.budgetTotal }
1575
- : {}),
1576
- // M3-3 / R43-N1: unmetered delegation stays available (documented
1577
- // pre-fix behavior for nesting runs without a budgetTotal) but is now
1578
- // an EXPLICIT, audited allow (`unmetered: true` on delegate.admitted).
1579
- unmeteredDelegates: "allow",
1580
- workspace: {
1581
- serializeEnabled: this.options.serializeOnPathOverlap === true,
1582
- overlappingInFlightExecutors: overlapping,
1583
- },
1584
- });
1585
- if (!decision.allowed) {
1586
- this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
1587
- subId,
1588
- reason: decision.reason,
1589
- message: (decision.message ?? "").slice(0, 120),
1590
- });
1591
- return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
1592
- }
1593
- // Slot acquisition INSIDE the lock (no reserve-then-race refund window).
1594
- if (!this.nestedSlotBudget().tryAcquire(subId)) {
1595
- this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
1596
- return {
1597
- code: "policy-denied" as const,
1598
- message: `delegate rejected: nested spawn budget exhausted; ${this.nestedSlotBudget().statusLine}`,
1599
- };
1600
- }
1601
- // Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
1602
- // += budgetTokens now. M3-3/R43-N1: the reservation ALSO seeds the parent
1603
- // allocation when the record has none — the `decision.budget.grantedTokens`
1604
- // the gate admitted against is the write-once `tokensGranted`
1605
- // (state-helpers.ts merge keeps disk's value verbatim). Pre-fix the
1606
- // `parentAllocation` guard silently skipped the reserve for every task
1607
- // that had no allocation, i.e. for every task.
1608
- // M3-4/R44-N4: this reservation IS the spend — no settle-time refund.
1609
- let reserved = 0;
1610
- const budgetDecision = decision.budget;
1611
- let tasksAfterReserve = fresh.tasks;
1612
- if (budgetDecision?.metered === true) {
1613
- reserved = budgetDecision.requestedTokens ?? 0;
1614
- const updatedTasks = fresh.tasks.map((t) =>
1615
- t.id === parentTaskId
1616
- ? {
1617
- ...t,
1618
- allocation: {
1619
- tokensGranted: budgetDecision.grantedTokens,
1620
- tokensSpent: budgetDecision.spentTokens + reserved,
1621
- },
1622
- }
1623
- : t,
1624
- );
1625
- tasksAfterReserve = updatedTasks;
1626
- saveRunTasks(fresh.manifest, updatedTasks);
1627
- }
1628
- // S1#1: register the grandchild SHADOW TASK — the subId identity gets a
1629
- // real depth/role entry (unbounded-chain fix). Built on tasksAfterReserve
1630
- // so the budget reservation above is never clobbered.
1631
- saveRunTasks(fresh.manifest, [
1632
- ...tasksAfterReserve,
1633
- {
1634
- id: subId,
1635
- runId,
1636
- role: requested.role ?? "explorer",
1637
- agent: "delegate",
1638
- title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
1639
- status: "queued",
1640
- cwd: executionCwd,
1641
- dependsOn: [],
1642
- depth: decision.childDepth ?? 2,
1643
- startedAt: new Date().toISOString(),
1644
- } satisfies TeamTaskState,
1645
- ]);
1646
- // RR-023 F3: thread the parent's model out of the locked read (gc fallback).
1647
- return { code: "ok" as const, decision, reserved, executionCwd, parentModel: task.model, parentRole: task.role };
1648
- });
1649
- // RR-023 F4 (finding #4): run.lock held by a live cross-process holder —
1650
- // degrade to a typed busy frame AND a delegate.rejected event (never
1651
- // silent); the connection survives (ping still answers).
1652
- if (!admissionAttempt.ok) {
1653
- this.recordDelegateEvent(loaded.manifest, "delegate.rejected", parentTaskId, { subId, reason: "run-lock-busy" });
1654
- this.sendError(conn, id, "busy", admissionAttempt.message);
1655
- return;
1656
- }
1657
- const admissionOutcome = admissionAttempt.value;
1658
- if (admissionOutcome.code !== "ok") {
1659
- this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
1660
- return;
1661
- }
1662
- const { decision, reserved, executionCwd, parentModel, parentRole } = admissionOutcome;
1663
- this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
1664
- subId,
1665
- childDepth: decision.childDepth,
1666
- role: requested.role ?? "explorer",
1667
- // M3-3/R43-N1: an unmetered allow is EXPLICIT in the audit trail.
1668
- ...(decision.budget?.metered === false ? { unmetered: true } : {}),
1669
- ...(reserved > 0 ? { reservedTokens: reserved } : {}),
1460
+ await handleDelegateRequest(conn, id, params, {
1461
+ sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
1462
+ sendResult: (c, i, r) => this.sendResult(c, i, r),
1463
+ options: this.options,
1464
+ nestedSlotBudget: () => this.nestedSlotBudget(),
1465
+ recordDelegateEvent: (m, type, taskId, data) => this.recordDelegateEvent(m, type, taskId, data),
1466
+ runLockBusyRetry: (manifest, fn) => this.runLockBusyRetry(manifest, fn),
1467
+ delegateAborts: this.delegateAborts,
1468
+ socketPath: this.socketPath,
1469
+ issueRunToken: (runId, taskId) => this.issueRunToken(runId, taskId),
1670
1470
  });
1671
- // S1#1: pre-mint the grandchild-scoped token when (and only when) the
1672
- // child may itself delegate (childDepth < resolved maxDepth — the same
1673
- // gate as issueForChild). Depth-2 at default maxDepth gets NO creds.
1674
- const grandchildCreds =
1675
- (decision.childDepth ?? 2) < (this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined))
1676
- ? { socketPath: this.socketPath, token: this.issueRunToken(runId, subId) }
1677
- : undefined;
1678
- // Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
1679
- this.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
1680
- // RR-023 F3 (finding #3, run team_20260929041427): model-less gc spawn fell
1681
- // through to the pi GLOBAL default — inherit the parent task's model.
1682
- const gcModel = requested.model ?? parentModel;
1683
- // M3-5/R43-N3: register the grandchild's abort controller BEFORE the
1684
- // spawn starts, so a run terminalise / budget abort / broker stop that
1685
- // lands mid-spawn still cancels it (no orphan outliving the run).
1686
- const delegateAbort = this.delegateAborts.register(subId, runId);
1687
- // Background grandchild lifecycle (ADR-5 §1/§6).
1688
- const spawner = this.options.grandchildSpawner ?? spawnDelegateGrandchild;
1689
- void (async () => {
1690
- let outcome: GrandchildSpawnResult;
1691
- try {
1692
- outcome = await spawner({
1693
- cwd: executionCwd,
1694
- runId,
1695
- parentTaskId,
1696
- // R43-S1: transitive tier containment — the spawner applies
1697
- // min(parentRole, requestedRole) as the grandchild tool policy.
1698
- parentRole,
1699
- subId,
1700
- prompt: requested.prompt,
1701
- eventsPath: loaded.manifest.eventsPath,
1702
- role: requested.role ?? "explorer",
1703
- ...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
1704
- ...(gcModel !== undefined ? { model: gcModel } : {}),
1705
- ...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
1706
- timeoutSec: decision.timeoutSec ?? 900,
1707
- depthOverride: decision.childDepth ?? 2,
1708
- // M3-5/R43-N3: owner cancellation (registry-driven).
1709
- signal: delegateAbort.signal,
1710
- // RR-012 F16: promote shadow queued→running once the process exists.
1711
- onSpawn: (pid) => {
1712
- if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
1713
- },
1714
- });
1715
- } catch (err) {
1716
- outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
1717
- }
1718
- try {
1719
- // S3 fence hardening: neutralize smuggled end-fence markers in the
1720
- // grandchild's raw text, then wrap (the client re-fences too — the
1721
- // mailbox is an unauthenticated same-uid channel by design).
1722
- const sanitizedText = outcome.resultText.replace(/^--- (end )?delegate /gm, "-- ~delegate ").slice(0, 65_536);
1723
- const statusTag = outcome.cancelled ? "(cancelled)" : outcome.timedOut ? "(timed out)" : outcome.ok ? "(ok)" : "(failed)";
1724
- const fenced = `--- delegate ${subId} ${statusTag} ---\n${sanitizedText}\n--- end delegate ${subId} ---`;
1725
- // Durable delivery + budget charge + shadow terminal flip — all
1726
- // inside one run lock, off a fresh disk read (delegate-finalize.ts
1727
- // owns the R43-N4 reroute and the R44-N4 broker-side charge).
1728
- finalizeDelegateResult({
1729
- brokerCwd: cwd,
1730
- runId,
1731
- parentTaskId,
1732
- subId,
1733
- fenced,
1734
- ok: outcome.ok,
1735
- ...(outcome.timedOut !== undefined ? { timedOut: outcome.timedOut } : {}),
1736
- reservedTokens: reserved,
1737
- ...(outcome.usageTokens !== undefined ? { selfReportedTokens: outcome.usageTokens } : {}),
1738
- });
1739
- } finally {
1740
- this.delegateAborts.release(subId);
1741
- this.nestedSlotBudget().release(subId);
1742
- }
1743
- this.recordDelegateEvent(loaded.manifest, delegateTerminalEvent(outcome), parentTaskId, {
1744
- subId,
1745
- ok: outcome.ok,
1746
- });
1747
- })();
1748
1471
  }
1749
1472
 
1750
1473
  // recordWaitPolicyRejection: imported directly from protocol/wait-auth.ts.
@@ -50,6 +50,17 @@ export function parseStdinBrokerPayload(raw: string, expectedRunId: string): Std
50
50
  }
51
51
  }
52
52
 
53
+ /** LOW-1 residual closure (2026-10-27): the ONE line a spawner writes to a
54
+ * worker's stdin carrying PER-TASK compound broker tokens. Moved here from
55
+ * async-runner.ts (which re-exports for API stability) so BOTH writer sides —
56
+ * the detached background runner AND the main child-pi spawn path — share the
57
+ * pure protocol piece and cannot drift from the reader parser below (a
58
+ * writer/reader drift silently breaks coordination). Compound (runId+taskId)
59
+ * tokens only: wait.* rejects bare-runId tokens (ADR-0 item 6). */
60
+ export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
61
+ return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
62
+ }
63
+
53
64
  /** Read the FIRST stdin line with a hard cap. Resolves undefined for: no
54
65
  * stdin / TTY / silence past the timeout / oversize / error. Never throws,
55
66
  * never destroys stdin (leaves it paused for the rest of boot). */
@@ -20,10 +20,12 @@ import type { SpawnOptions } from "node:child_process";
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  import { childPiBaseAllowlist } from "../../config/env-allowlist-registry.ts";
23
+ import { getCrewEnv } from "../../config/env-vars.ts";
23
24
  import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
24
25
  import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
25
26
  import { logInternalError } from "../../utils/internal-error.ts";
26
27
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
28
+ import { buildBrokerStdinLine } from "../broker/stdin-handshake.ts";
27
29
  import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
28
30
  import { getPiSpawnCommand } from "../pi-spawn.ts";
29
31
  import { findLatestScratchpadSnapshot } from "../scratchpad/snapshot-lookup.ts";
@@ -44,6 +46,19 @@ import {
44
46
  // NOT set this (their sessions persist and benefit from long-lived cache).
45
47
  export const PI_CACHE_RETENTION_ENV = "PI_CACHE_RETENTION";
46
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. */
58
+ export function resolveBrokerStdinHandshakeEnabled(): boolean {
59
+ return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
60
+ }
61
+
47
62
  // ── Env allowlist (base set always passed to children) ──────────────────
48
63
  // Provider API keys are injected dynamically via buildScopedAllowList() only
49
64
  // when a model is assigned to the task (per-task key scoping).
@@ -258,6 +273,7 @@ export function buildFinalChildPiSpawnOptions(
258
273
  model?: string,
259
274
  modelChain?: (string | undefined)[],
260
275
  workerCacheSplit?: boolean,
276
+ stdinBrokerPayload?: string,
261
277
  ): SpawnOptions {
262
278
  // (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
263
279
  assertOnlyControlEnvKeys(builtEnv);
@@ -287,7 +303,18 @@ export function buildFinalChildPiSpawnOptions(
287
303
  if (workerCacheSplit) {
288
304
  spawnOptions.env[PI_CACHE_RETENTION_ENV] = "short";
289
305
  }
290
- // (d) Return the composed SpawnOptions for spawn(...).
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.
313
+ if (stdinBrokerPayload !== undefined) {
314
+ spawnOptions.stdio = ["pipe", "pipe", "pipe"];
315
+ delete spawnOptions.env.PI_CREW_BROKER_TOKEN;
316
+ }
317
+ // (e) Return the composed SpawnOptions for spawn(...).
291
318
  return spawnOptions;
292
319
  }
293
320
 
@@ -310,6 +337,12 @@ export interface SpawnContext {
310
337
  builtEnv: Record<string, string | undefined>;
311
338
  /** v0.11.31: inference-broker worker key that owns this spawn's token (cleanup/revoke bookkeeping). */
312
339
  inferenceWorkerKey?: string;
340
+ /** 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. */
345
+ brokerStdinPayload?: string;
313
346
  }
314
347
 
315
348
  /**
@@ -364,6 +397,9 @@ export function prepareSpawnContext(
364
397
  // a per-spawn unique.
365
398
  const inferenceSnapshot = requireInferenceBrokerIfEnabled();
366
399
  let inferenceWorkerKey: string | undefined;
400
+ // LOW-1 closure: stdin handshake payload for the headless JSON branch (see
401
+ // the brokerSpawn block below). Undefined = env route (legacy/rollback).
402
+ let brokerStdinPayload: string | undefined;
367
403
  if (inferenceSnapshot.active) {
368
404
  // v0.11.36: the agentId derivation is SHARED with the exit-revoke wrapper
369
405
  // (runChildPi finally) via inferenceWorkerKeyForAgent — both sides must
@@ -415,11 +451,23 @@ export function prepareSpawnContext(
415
451
  // Phase 0 inter-pi broker: inject socket path + token (control-namespace keys,
416
452
  // safe under assertOnlyControlEnvKeys). Only when the parent broker issued
417
453
  // credentials for this run — i.e. the broker is enabled AND this run is
418
- // eligible. The token is heap-only on the parent; the child receives it
419
- // solely through env. NEVER persisted to disk.
454
+ // 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.
420
462
  if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
421
463
  built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
422
- built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
464
+ if (resolveBrokerStdinHandshakeEnabled() && input.runId && input.agentId) {
465
+ brokerStdinPayload = buildBrokerStdinLine(input.runId, input.brokerSpawn.socketPath, {
466
+ [input.agentId]: input.brokerSpawn.token,
467
+ });
468
+ } else {
469
+ built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
470
+ }
423
471
  // runId/taskId are threaded unconditionally above (I5); nothing to add here.
424
472
  }
425
473
  // WP-9 (R9): the worker self-reporting channel env is UNCONDITIONAL —
@@ -499,6 +547,7 @@ export function prepareSpawnContext(
499
547
  tempDir: built.tempDir,
500
548
  builtEnv: built.env,
501
549
  inferenceWorkerKey,
550
+ ...(brokerStdinPayload !== undefined ? { brokerStdinPayload } : {}),
502
551
  },
503
552
  };
504
553
  }
@@ -852,7 +852,18 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
852
852
  // R6-8: modelChain threads the candidate chain for provider-key scoping.
853
853
  // v0.11.38 W10-1: workerCacheSplit — headless single-run workers opt into
854
854
  // short prompt-cache retention (see PI_CACHE_RETENTION_ENV).
855
- const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain, true);
855
+ // LOW-1 closure: ctx.brokerStdinPayload (when present) switches stdin to
856
+ // "pipe" + strips PI_CREW_BROKER_TOKEN from the child env — the token
857
+ // travels over the stdin pipe right after spawn instead (heap → pipe → heap).
858
+ const spawnOptions = buildFinalChildPiSpawnOptions(
859
+ input.cwd,
860
+ mergedEnv,
861
+ builtEnv,
862
+ input.model,
863
+ input.modelChain,
864
+ true,
865
+ spawnPrep.ctx.brokerStdinPayload,
866
+ );
856
867
  // v0.11.36 OS boundary: when the sandbox is on, the headless spawn is
857
868
  // wrapped in bwrap. Mounts: cwd RW (the worker edits the project), the
858
869
  // scoped inference agent dir RW (pi writes session state under it), and
@@ -909,6 +920,26 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
909
920
  ts: new Date().toISOString(),
910
921
  });
911
922
  }
923
+ // LOW-1 closure: hand the worker its broker credential over stdin — one
924
+ // buildBrokerStdinLine JSON line (v2, per-task compound token), then close
925
+ // the pipe. Mirror of the F4 async-runner handshake; the worker-side
926
+ // reader (crew-broker-child.ts via stdin-handshake.readStdinFirstLine)
927
+ // binds it by runId + taskId. Best-effort in BOTH directions: any write
928
+ // failure (EPIPE on a fast-exiting child, destroyed stream) leaves the
929
+ // worker creds-less exactly like a broker-less spawn — never throws out
930
+ // of the spawn path.
931
+ if (spawnPrep.ctx.brokerStdinPayload) {
932
+ try {
933
+ child.stdin?.write(spawnPrep.ctx.brokerStdinPayload);
934
+ } catch {
935
+ /* best-effort: worker proceeds creds-less */
936
+ }
937
+ try {
938
+ child.stdin?.end();
939
+ } catch {
940
+ /* EPIPE: child already died — spawn error handling owns that */
941
+ }
942
+ }
912
943
  // P0-1: O(1)-amortized bounded accumulators (segment ring) — replaces the O(n²)
913
944
  // appendBoundedTail rebuild-per-line pattern that re-scanned 512 KiB every line.
914
945
  const stdoutTail = new BoundedTail();