pi-crew 0.11.44 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.44",
3
+ "version": "0.11.46",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -198,6 +198,10 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
198
198
  reason: "per-task compound broker token via built.env/pipe (F4) — secret, never allowlisted",
199
199
  },
200
200
  PI_CREW_BROKER_TASK_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
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)",
204
+ },
201
205
  PI_CREW_INFERENCE_BROKER: {
202
206
  kind: "gap",
203
207
  reason: "v0.11.31 inference broker toggle — resolved in the PARENT only (ensureInferenceBroker); deliberately never forwarded to children (a child re-starting its own broker would be nonsensical)",
@@ -666,6 +666,10 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
666
666
  name: "PI_CREW_BROKER_TASK_ID",
667
667
  doc: "broker task id for child handshake (crew-broker-child.ts:51)",
668
668
  },
669
+ PI_CREW_BROKER_STDIN: {
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)",
672
+ },
669
673
  PI_CREW_GUEST: {
670
674
  name: "PI_CREW_GUEST",
671
675
  doc: "'1' marks the guest scratchpad process (written by scratchpad-lifecycle.ts:167)",
@@ -165,12 +165,21 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => un
165
165
  // pi-crew:rpc:live-control, and the realtime pi-crew:live-control channel) are
166
166
  // accessible to any extension on the shared event bus. Mitigations: rate
167
167
  // limiting, explicit intent, operation allowlist, cwd containment validation,
168
- // and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9): without a
169
- // valid HMAC signature every RPC surface is read-only — the request/response
170
- // channels enforce this via RPC_ALLOWED_OPERATIONS, and the realtime
171
- // pi-crew:live-control channel rejects any operation outside
172
- // LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS (currently ALL of them, since
173
- // steer/stop/resume/follow-up are mutating) with a warn log and no publish.
168
+ // and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9) — what a
169
+ // publisher WITHOUT a valid HMAC signature may do:
170
+ // - pi-crew:rpc:run is the ONLY mutating channel that accepts unsigned
171
+ // requests. This is deliberate (SEC-03, test-pinned backward-compat):
172
+ // each call is rate-limited (RPC_RATE_LIMIT_MAX requests per
173
+ // RPC_RATE_LIMIT_WINDOW_MS — 5/60s), the payload is filtered to the
174
+ // ALLOWED_RPC_RUN_KEYS allowlist (unknown keys are stripped before
175
+ // handleTeamTool ever sees them), and config.cwd must pass containment
176
+ // validation (inside the project directory).
177
+ // - The remaining request/response channels are read-only for unsigned
178
+ // publishers, enforced via RPC_ALLOWED_OPERATIONS.
179
+ // - The realtime pi-crew:live-control channel rejects ALL unsigned
180
+ // operations — LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS is the empty set
181
+ // (steer/stop/resume/follow-up are all mutating) — with a warn log and no
182
+ // publish.
174
183
 
175
184
  export function registerPiCrewRpc(
176
185
  events: EventBusLike | undefined,
@@ -4,7 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { type Static, Type } from "@sinclair/typebox";
5
5
  import { getCrewEnv } from "../config/env-vars.ts";
6
6
  import { defineTool, type ToolDefinition } from "../extension/pi-api.ts";
7
- import { startChildBrokerClient } from "../runtime/broker/crew-broker-child.ts";
7
+ import { type ChildBrokerClientHandle, startChildBrokerClientAsync } from "../runtime/broker/crew-broker-child.ts";
8
8
  import { CrewBrokerClient } from "../runtime/broker/crew-broker-client.ts";
9
9
  import { hasLiveControlRealtimeListeners } from "../runtime/live-session/live-control-realtime.ts";
10
10
  import { checkReplyToRequesterOrigin, type MailboxMessage, readAllMailboxMessages } from "../state/coordination/mailbox.ts";
@@ -1191,7 +1191,14 @@ 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
- const brokerHandle = startChildBrokerClient({
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
1196
+ // 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
+ // handle is live at that moment, and the late arrival closes itself.
1199
+ let brokerHandle: ChildBrokerClientHandle = { active: false, close: async () => undefined };
1200
+ let brokerShutdownSeen = false;
1201
+ startChildBrokerClientAsync({
1195
1202
  onSteer: (rawMessage, id) => {
1196
1203
  // FIX-S1: cross-channel dedup. The broker persists every steer to
1197
1204
  // the steering JSONL (durable) AND pushes it via the mailbox
@@ -1212,12 +1219,18 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
1212
1219
  }
1213
1220
  pi.sendMessage({ customType: "crew-steer", content: sanitized.message, display: false }, { deliverAs: "steer" });
1214
1221
  },
1215
- });
1222
+ })
1223
+ .then((handle) => {
1224
+ brokerHandle = handle;
1225
+ if (brokerShutdownSeen) void brokerHandle.close().catch(() => undefined);
1226
+ })
1227
+ .catch(() => undefined);
1216
1228
  // Close the broker connection on session shutdown to avoid leaking the
1217
1229
  // persistent socket / reconnect timer. Fire-and-forget: the socket is
1218
1230
  // already .unref()'d so it never blocks event-loop exit; errors are
1219
1231
  // swallowed because teardown failures during shutdown are harmless.
1220
1232
  pi.on("session_shutdown", () => {
1233
+ brokerShutdownSeen = true;
1221
1234
  void brokerHandle.close().catch(() => undefined);
1222
1235
  });
1223
1236
 
@@ -11,6 +11,7 @@ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
11
11
  import { logInternalError } from "../utils/internal-error.ts";
12
12
  import { packageRoot } from "../utils/paths.ts";
13
13
  import { redactSecretString } from "../utils/redaction.ts";
14
+ import { buildBrokerStdinLine } from "./broker/stdin-handshake.ts";
14
15
  import { registerWorker, unregisterWorker } from "./orphan-worker-registry.ts";
15
16
  import { PEER_DEP_DIR_ENV, resolvePeerDepDir } from "./peer-dep.ts";
16
17
 
@@ -215,13 +216,10 @@ export function forwardModelControlEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEn
215
216
  }
216
217
 
217
218
  /** F4 v2: the ONE line written to the background-runner's stdin carrying
218
- * PER-TASK compound broker tokens (see stdin-handshake.ts for why compound —
219
- * ADR-0 item 6 rejects bare-runId tokens for wait.*). Kept pure + exported so
220
- * tests pin the WRITER format against the READER parser — a drift between
221
- * the two silently breaks coordination for every async run. */
222
- export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
223
- return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
224
- }
219
+ * PER-TASK compound broker tokens. MOVED to broker/stdin-handshake.ts (pure
220
+ * protocol piece, shared with the child-pi spawn writer — LOW-1 closure);
221
+ * re-exported here so the writer API and existing test imports stay stable. */
222
+ export { buildBrokerStdinLine } from "./broker/stdin-handshake.ts";
225
223
 
226
224
  /**
227
225
  * IN-PROCESS ASYNC TEST SEAM (kills two classes of test pain):
@@ -15,7 +15,9 @@
15
15
  */
16
16
 
17
17
  import type * as net from "node:net";
18
+ import { getCrewEnv } from "../../config/env-vars.ts";
18
19
  import { type BrokerEventFrame, CrewBrokerClient, type CrewBrokerClientOptions } from "./crew-broker-client.ts";
20
+ import { parseStdinBrokerPayload, readStdinFirstLine } from "./stdin-handshake.ts";
19
21
 
20
22
  export interface ChildBrokerClientHandle {
21
23
  /** True once the client has completed the handshake. */
@@ -37,6 +39,9 @@ export interface StartChildBrokerClientOptions {
37
39
  netModule?: typeof net;
38
40
  /** Test seam: override client construction. */
39
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>;
40
45
  }
41
46
 
42
47
  const NOOP_HANDLE: ChildBrokerClientHandle = {
@@ -86,3 +91,56 @@ export function startChildBrokerClient(options: StartChildBrokerClientOptions =
86
91
  },
87
92
  };
88
93
  }
94
+
95
+ /** True when this process was booted as an RPC-mode worker (pi --mode rpc):
96
+ * its stdin IS the JSONL protocol channel and must never be consumed by the
97
+ * credential handshake. Those spawns keep the token in env (the RPC branch of
98
+ * 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
+ function argvIndicatesRpcMode(): boolean {
101
+ return process.argv.includes("--mode") && process.argv.includes("rpc");
102
+ }
103
+
104
+ /** LOW-1 residual closure (2026-10-27): async wrapper used by the worker
105
+ * bootstrap (prompt-runtime Feature 2b). Identical contract to
106
+ * {@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.
116
+ * The sync {@link startChildBrokerClient} stays untouched (env-only) so every
117
+ * existing caller/test keeps its exact behavior. */
118
+ export async function startChildBrokerClientAsync(options: StartChildBrokerClientOptions = {}): Promise<ChildBrokerClientHandle> {
119
+ 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()) {
124
+ try {
125
+ const raw = await (options.readStdinLine ?? readStdinFirstLine)();
126
+ const payload = raw ? parseStdinBrokerPayload(raw, runId) : undefined;
127
+ // socketPath cross-check: the payload must describe the SAME socket the
128
+ // parent announced via env (both are parent-controlled, but a mismatch
129
+ // means skew/garbage — refuse rather than connect with mixed creds).
130
+ const token = payload?.socketPath === socketPath ? payload.tasks[taskId] : undefined;
131
+ if (token) {
132
+ env = { ...env, PI_CREW_BROKER_TOKEN: token };
133
+ }
134
+ } catch {
135
+ /* best-effort: fall through creds-less (previous behavior) */
136
+ }
137
+ }
138
+ return startChildBrokerClient({ ...options, env });
139
+ }
140
+
141
+ /** 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";
146
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * crew-broker-delegate-request.ts — `delegate.request` broker handler
3
+ * (governed-nesting grandchild spawn).
4
+ *
5
+ * Moved from crew-broker.ts:1459-1748 (wc-gate ITEM C, pure move, no
6
+ * behavior change, 2026-10-27). Top-level free function so the class
7
+ * method stays a 1-line delegation (same deps-object pattern as
8
+ * protocol/wait-resolve.ts / protocol/msg-inbox.ts).
9
+ *
10
+ * Behavior contract (unchanged — ADR-5 §1-§12):
11
+ * - Auth: task-scoped token MANDATORY (worker role + compound auth
12
+ * match; ADR-5 pin iii) — plain run tokens are rejected `forbidden`.
13
+ * - Params parsed through the protocol parser (R7-H1: bad maxTurns /
14
+ * budgetTokens never reach admission or the slot reservation).
15
+ * - Capability gate `nestingEnabled` fail-closed (never silent —
16
+ * delegate.rejected recorded + typed `policy-disabled` frame).
17
+ * - Admission matrix (spawn-policy) evaluated under a bounded
18
+ * run-lock busy-retry (RR-023 F4) off a FRESH disk read; lock-busy
19
+ * degrades to a typed `busy` frame + delegate.rejected event, the
20
+ * connection SURVIVES. Nested slot is acquired INSIDE the lock
21
+ * (S1#1: no reserve-then-race refund window) and the grandchild
22
+ * token scopes to a subId minted BEFORE admission.
23
+ * - Budget reservation is pessimistic and seeds the parent allocation
24
+ * (M3-3/R43-N1); the reservation IS the spend (M3-4/R44-N4).
25
+ * - Grandchild SHADOW TASK registered in the same locked RMW (S1#1).
26
+ * - Responds IMMEDIATELY with `grandchildTaskRef` (principle 7: the
27
+ * tool self-polls the mailbox); the grandchild lifecycle runs in a
28
+ * background IIFE: abort-controller registered BEFORE the spawn
29
+ * (M3-5/R43-N3), parent-model inheritance (RR-023 F3), shadow
30
+ * queued→running promotion on live pid (RR-012 F16), end-fence
31
+ * neutralization before durable mailbox delivery + budget charge +
32
+ * shadow terminal flip (finalizeDelegateResult), abort + nested
33
+ * slot released in a `finally`, terminal delegate event recorded.
34
+ *
35
+ * Leaf-module imports ONLY — never import crew-broker.ts (no cycle).
36
+ * Broker-owned collaborators (token registry, socket path, nested-slot
37
+ * budget singleton, abort registry, send helpers, options slice) are
38
+ * threaded through the explicit {@link DelegateRequestDeps} object.
39
+ */
40
+
41
+ import { randomUUID } from "node:crypto";
42
+ import { loadRunManifestById, saveRunTasks } from "../../state/stores/state-store.ts";
43
+ import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
44
+ import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
45
+ import { resolveCrewMaxDepth } from "../model/pi-args.ts";
46
+ import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
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";
50
+ import { delegateTerminalEvent, finalizeDelegateResult } from "./delegate/delegate-finalize.ts";
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";
54
+ import { parseDelegateRequestParams } from "./protocol/request-parsers.ts";
55
+
56
+ /** Options slice the delegate handler reads off the broker's RESOLVED
57
+ * options (single source of truth: CrewBrokerOptions in
58
+ * protocol/connection-state.ts — field docs live there). */
59
+ export type DelegateRequestOptions = Pick<
60
+ CrewBrokerOptions,
61
+ | "cwd"
62
+ | "nestingEnabled"
63
+ | "nestingMaxDepth"
64
+ | "nestingTrustedEscalation"
65
+ | "serializeOnPathOverlap"
66
+ | "lockBusyRetryDelaysMs"
67
+ | "grandchildSpawner"
68
+ | "modelCatalog"
69
+ >;
70
+
71
+ /** Broker-owned collaborators the moved handler needs. The broker binds
72
+ * these once per dispatch (see CrewBroker.handleDelegateRequest). */
73
+ export interface DelegateRequestDeps {
74
+ sendError(conn: ServerConnection, id: string, code: string, message: string): void;
75
+ sendResult(conn: ServerConnection, id: string, result: unknown): void;
76
+ /** Resolved broker options (cwd + nesting gates + spawner/catalog seams). */
77
+ options: DelegateRequestOptions;
78
+ /** ADR-5 §5: lazy nested-slot budget singleton (broker owns sizing). */
79
+ nestedSlotBudget(): NestedSlotBudget;
80
+ /** Delegate event recorder (delegate-event.ts passthrough — never silent). */
81
+ recordDelegateEvent(manifest: DelegateEventTarget, type: DelegateEventType, taskId: string, data: Record<string, unknown>): void;
82
+ /** RR-023 F4: bounded busy-retry around a run-locked RMW (protocol/lock-busy.ts). */
83
+ runLockBusyRetry<T>(manifest: TeamRunManifest, fn: () => T): Promise<LockBusyOutcome<T>>;
84
+ /** M3-5/R43-N3: broker-owned grandchild abort registry (run-terminal + stop). */
85
+ delegateAborts: DelegateAbortRegistry;
86
+ /** Resolved socket path (broker getter) — threaded into grandchild creds. */
87
+ socketPath: string;
88
+ /** Mint a fresh grandchild-scoped run token (S1#1 pre-mint; heap-only). */
89
+ issueRunToken(runId: string, taskId: string): string;
90
+ }
91
+
92
+ export async function handleDelegateRequest(conn: ServerConnection, id: string, params: unknown, deps: DelegateRequestDeps): Promise<void> {
93
+ // Auth: task-scoped token MANDATORY (ADR-5 pin iii) — same rule as wait.*.
94
+ if (!conn.runId || !conn.taskId) {
95
+ deps.sendError(conn, id, "auth", "not authed");
96
+ return;
97
+ }
98
+ if (conn.role !== "worker" || conn.authMatchKind !== "compound") {
99
+ deps.sendError(conn, id, "forbidden", "delegate requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID");
100
+ return;
101
+ }
102
+ // R7-H1 fix: parse params through the protocol-level parser (mirrors
103
+ // parseWaitRequestParams discipline) — non-integer/non-finite/negative/
104
+ // over-ceiling maxTurns/budgetTokens never reach admission or the
105
+ // budget reservation (durable-negative tokensSpent escape).
106
+ const parsed = parseDelegateRequestParams(params);
107
+ if (!parsed) {
108
+ deps.sendError(conn, id, "bad-params", "delegate.request: invalid params");
109
+ return;
110
+ }
111
+ const requested = parsed;
112
+ const cwd = deps.options.cwd;
113
+ if (!cwd) {
114
+ deps.sendError(conn, id, "no-manifest", "broker has no cwd configured");
115
+ return;
116
+ }
117
+ let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
118
+ try {
119
+ const l = loadRunManifestById(cwd, conn.runId);
120
+ if (!l) {
121
+ deps.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
122
+ return;
123
+ }
124
+ loaded = l;
125
+ } catch (err) {
126
+ deps.sendError(conn, id, "no-manifest", (err as Error).message);
127
+ return;
128
+ }
129
+ // Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
130
+ // flip the DEFAULT is true, so reaching this branch means the user
131
+ // closed the surface via config — the message points back at the knob.
132
+ if (deps.options.nestingEnabled !== true) {
133
+ deps.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
134
+ reason: "nesting-disabled",
135
+ policy: "nesting.enabled=false (user config; default is true since D8)",
136
+ });
137
+ deps.sendError(
138
+ conn,
139
+ id,
140
+ "policy-disabled",
141
+ "delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
142
+ );
143
+ return;
144
+ }
145
+ const runId = conn.runId;
146
+ const parentTaskId = conn.taskId;
147
+ // S1#1 fix (security round 1): the subId is minted BEFORE admission so the
148
+ // grandchild token scopes to the SUBID (never the parent task key) and the
149
+ // nested slot is acquired INSIDE the same lock as the admission snapshot.
150
+ const subId = `gc-${randomUUID()}`;
151
+ deps.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
152
+ // Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
153
+ // the RECORD under the run lock — never the worker's env/self-report.
154
+ // RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
155
+ const admissionAttempt = await deps.runLockBusyRetry(loaded.manifest, () => {
156
+ const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
157
+ if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
158
+ const task = fresh.tasks.find((t) => t.id === parentTaskId);
159
+ if (!task) {
160
+ deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "no-task" });
161
+ return { code: "no-task" as const, message: `task '${parentTaskId}' not found` };
162
+ }
163
+ if (task.status !== "running") {
164
+ deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
165
+ subId,
166
+ reason: "parent-not-running",
167
+ message: `task is ${task.status}`,
168
+ });
169
+ return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
170
+ }
171
+ // RR-012 F03: authoritative execution cwd = the parent task's cwd from
172
+ // this locked fresh read — ONE source for overlap check, shadow record
173
+ // and spawner input; broker cwd stays for manifest lookup only.
174
+ const executionCwd = task.cwd;
175
+ const catalog = deps.options.modelCatalog?.();
176
+ // S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
177
+ const effectiveCatalog = deps.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
178
+ // ADR-5 §9: count OTHER in-flight executor-class tasks sharing the
179
+ // parent task's cwd (manifest record — single source of truth).
180
+ const overlapping = fresh.tasks.filter(
181
+ (t) =>
182
+ t.id !== parentTaskId &&
183
+ t.status === "running" &&
184
+ (t.role === "executor" || t.role === "test-engineer") &&
185
+ t.cwd === executionCwd,
186
+ ).length;
187
+ const decision = evaluateDelegateAdmission({
188
+ maxDepth: deps.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
189
+ parentTask: {
190
+ taskId: parentTaskId,
191
+ role: task.role,
192
+ ...(task.depth !== undefined ? { depth: task.depth } : {}),
193
+ ...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
194
+ },
195
+ slots: deps.nestedSlotBudget().snapshot(),
196
+ requested,
197
+ ...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
198
+ // ADR-5 §12: the delegate surface is an escalation — trusted only by the
199
+ // explicit user opt-in threaded from config.nesting.enabled (sensitive).
200
+ untrusted: deps.options.nestingTrustedEscalation !== true,
201
+ // M3-3 / R43-N1 SEED: the run-level token budget is the grant every
202
+ // task inherits when its own record carries no allocation (pre-fix
203
+ // state of EVERY task — nothing produced `tokensGranted`, so every
204
+ // budgeted delegate was denied and every unmetered one slipped by).
205
+ // `budgetUnlimited` opts out of enforcement entirely ⇒ no seed.
206
+ ...(fresh.manifest.budgetTotal !== undefined && fresh.manifest.budgetUnlimited !== true
207
+ ? { parentBudgetGrantTokens: fresh.manifest.budgetTotal }
208
+ : {}),
209
+ // M3-3 / R43-N1: unmetered delegation stays available (documented
210
+ // pre-fix behavior for nesting runs without a budgetTotal) but is now
211
+ // an EXPLICIT, audited allow (`unmetered: true` on delegate.admitted).
212
+ unmeteredDelegates: "allow",
213
+ workspace: {
214
+ serializeEnabled: deps.options.serializeOnPathOverlap === true,
215
+ overlappingInFlightExecutors: overlapping,
216
+ },
217
+ });
218
+ if (!decision.allowed) {
219
+ deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
220
+ subId,
221
+ reason: decision.reason,
222
+ message: (decision.message ?? "").slice(0, 120),
223
+ });
224
+ return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
225
+ }
226
+ // Slot acquisition INSIDE the lock (no reserve-then-race refund window).
227
+ if (!deps.nestedSlotBudget().tryAcquire(subId)) {
228
+ deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
229
+ return {
230
+ code: "policy-denied" as const,
231
+ message: `delegate rejected: nested spawn budget exhausted; ${deps.nestedSlotBudget().statusLine}`,
232
+ };
233
+ }
234
+ // Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
235
+ // += budgetTokens now. M3-3/R43-N1: the reservation ALSO seeds the parent
236
+ // allocation when the record has none — the `decision.budget.grantedTokens`
237
+ // the gate admitted against is the write-once `tokensGranted`
238
+ // (state-helpers.ts merge keeps disk's value verbatim). Pre-fix the
239
+ // `parentAllocation` guard silently skipped the reserve for every task
240
+ // that had no allocation, i.e. for every task.
241
+ // M3-4/R44-N4: this reservation IS the spend — no settle-time refund.
242
+ let reserved = 0;
243
+ const budgetDecision = decision.budget;
244
+ let tasksAfterReserve = fresh.tasks;
245
+ if (budgetDecision?.metered === true) {
246
+ reserved = budgetDecision.requestedTokens ?? 0;
247
+ const updatedTasks = fresh.tasks.map((t) =>
248
+ t.id === parentTaskId
249
+ ? {
250
+ ...t,
251
+ allocation: {
252
+ tokensGranted: budgetDecision.grantedTokens,
253
+ tokensSpent: budgetDecision.spentTokens + reserved,
254
+ },
255
+ }
256
+ : t,
257
+ );
258
+ tasksAfterReserve = updatedTasks;
259
+ saveRunTasks(fresh.manifest, updatedTasks);
260
+ }
261
+ // S1#1: register the grandchild SHADOW TASK — the subId identity gets a
262
+ // real depth/role entry (unbounded-chain fix). Built on tasksAfterReserve
263
+ // so the budget reservation above is never clobbered.
264
+ saveRunTasks(fresh.manifest, [
265
+ ...tasksAfterReserve,
266
+ {
267
+ id: subId,
268
+ runId,
269
+ role: requested.role ?? "explorer",
270
+ agent: "delegate",
271
+ title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
272
+ status: "queued",
273
+ cwd: executionCwd,
274
+ dependsOn: [],
275
+ depth: decision.childDepth ?? 2,
276
+ startedAt: new Date().toISOString(),
277
+ } satisfies TeamTaskState,
278
+ ]);
279
+ // RR-023 F3: thread the parent's model out of the locked read (gc fallback).
280
+ return { code: "ok" as const, decision, reserved, executionCwd, parentModel: task.model, parentRole: task.role };
281
+ });
282
+ // RR-023 F4 (finding #4): run.lock held by a live cross-process holder —
283
+ // degrade to a typed busy frame AND a delegate.rejected event (never
284
+ // silent); the connection survives (ping still answers).
285
+ if (!admissionAttempt.ok) {
286
+ deps.recordDelegateEvent(loaded.manifest, "delegate.rejected", parentTaskId, { subId, reason: "run-lock-busy" });
287
+ deps.sendError(conn, id, "busy", admissionAttempt.message);
288
+ return;
289
+ }
290
+ const admissionOutcome = admissionAttempt.value;
291
+ if (admissionOutcome.code !== "ok") {
292
+ deps.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
293
+ return;
294
+ }
295
+ const { decision, reserved, executionCwd, parentModel, parentRole } = admissionOutcome;
296
+ deps.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
297
+ subId,
298
+ childDepth: decision.childDepth,
299
+ role: requested.role ?? "explorer",
300
+ // M3-3/R43-N1: an unmetered allow is EXPLICIT in the audit trail.
301
+ ...(decision.budget?.metered === false ? { unmetered: true } : {}),
302
+ ...(reserved > 0 ? { reservedTokens: reserved } : {}),
303
+ });
304
+ // S1#1: pre-mint the grandchild-scoped token when (and only when) the
305
+ // child may itself delegate (childDepth < resolved maxDepth — the same
306
+ // gate as issueForChild). Depth-2 at default maxDepth gets NO creds.
307
+ const grandchildCreds =
308
+ (decision.childDepth ?? 2) < (deps.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined))
309
+ ? { socketPath: deps.socketPath, token: deps.issueRunToken(runId, subId) }
310
+ : undefined;
311
+ // Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
312
+ deps.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
313
+ // RR-023 F3 (finding #3, run team_20260929041427): model-less gc spawn fell
314
+ // through to the pi GLOBAL default — inherit the parent task's model.
315
+ const gcModel = requested.model ?? parentModel;
316
+ // M3-5/R43-N3: register the grandchild's abort controller BEFORE the
317
+ // spawn starts, so a run terminalise / budget abort / broker stop that
318
+ // lands mid-spawn still cancels it (no orphan outliving the run).
319
+ const delegateAbort = deps.delegateAborts.register(subId, runId);
320
+ // Background grandchild lifecycle (ADR-5 §1/§6).
321
+ const spawner = deps.options.grandchildSpawner ?? spawnDelegateGrandchild;
322
+ void (async () => {
323
+ let outcome: GrandchildSpawnResult;
324
+ try {
325
+ outcome = await spawner({
326
+ cwd: executionCwd,
327
+ runId,
328
+ parentTaskId,
329
+ // R43-S1: transitive tier containment — the spawner applies
330
+ // min(parentRole, requestedRole) as the grandchild tool policy.
331
+ parentRole,
332
+ subId,
333
+ prompt: requested.prompt,
334
+ eventsPath: loaded.manifest.eventsPath,
335
+ role: requested.role ?? "explorer",
336
+ ...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
337
+ ...(gcModel !== undefined ? { model: gcModel } : {}),
338
+ ...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
339
+ timeoutSec: decision.timeoutSec ?? 900,
340
+ depthOverride: decision.childDepth ?? 2,
341
+ // M3-5/R43-N3: owner cancellation (registry-driven).
342
+ signal: delegateAbort.signal,
343
+ // RR-012 F16: promote shadow queued→running once the process exists.
344
+ onSpawn: (pid) => {
345
+ if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
346
+ },
347
+ });
348
+ } catch (err) {
349
+ outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
350
+ }
351
+ try {
352
+ // S3 fence hardening: neutralize smuggled end-fence markers in the
353
+ // grandchild's raw text, then wrap (the client re-fences too — the
354
+ // mailbox is an unauthenticated same-uid channel by design).
355
+ const sanitizedText = outcome.resultText.replace(/^--- (end )?delegate /gm, "-- ~delegate ").slice(0, 65_536);
356
+ const statusTag = outcome.cancelled ? "(cancelled)" : outcome.timedOut ? "(timed out)" : outcome.ok ? "(ok)" : "(failed)";
357
+ const fenced = `--- delegate ${subId} ${statusTag} ---\n${sanitizedText}\n--- end delegate ${subId} ---`;
358
+ // Durable delivery + budget charge + shadow terminal flip — all
359
+ // inside one run lock, off a fresh disk read (delegate-finalize.ts
360
+ // owns the R43-N4 reroute and the R44-N4 broker-side charge).
361
+ finalizeDelegateResult({
362
+ brokerCwd: cwd,
363
+ runId,
364
+ parentTaskId,
365
+ subId,
366
+ fenced,
367
+ ok: outcome.ok,
368
+ ...(outcome.timedOut !== undefined ? { timedOut: outcome.timedOut } : {}),
369
+ reservedTokens: reserved,
370
+ ...(outcome.usageTokens !== undefined ? { selfReportedTokens: outcome.usageTokens } : {}),
371
+ });
372
+ } finally {
373
+ deps.delegateAborts.release(subId);
374
+ deps.nestedSlotBudget().release(subId);
375
+ }
376
+ deps.recordDelegateEvent(loaded.manifest, delegateTerminalEvent(outcome), parentTaskId, {
377
+ subId,
378
+ ok: outcome.ok,
379
+ });
380
+ })();
381
+ }