pi-crew 0.10.4 → 0.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/agents/analyst.md +37 -2
  3. package/agents/cold-verifier.md +10 -1
  4. package/agents/councillor-critic.md +39 -0
  5. package/agents/councillor-pragmatist.md +39 -0
  6. package/agents/councillor-skeptic.md +41 -0
  7. package/agents/critic.md +40 -2
  8. package/agents/designer.md +58 -0
  9. package/agents/executor.md +39 -2
  10. package/agents/explorer.md +38 -2
  11. package/agents/librarian.md +49 -0
  12. package/agents/oracle.md +54 -0
  13. package/agents/orchestrator.md +48 -0
  14. package/agents/planner.md +41 -2
  15. package/agents/reviewer.md +39 -2
  16. package/agents/security-reviewer.md +43 -2
  17. package/agents/test-engineer.md +48 -2
  18. package/agents/verifier.md +14 -1
  19. package/agents/writer.md +32 -2
  20. package/dist/index.mjs +1297 -853
  21. package/package.json +1 -1
  22. package/skills/async-worker-recovery/SKILL.md +4 -1
  23. package/skills/child-pi-spawning/SKILL.md +4 -1
  24. package/skills/context-artifact-hygiene/SKILL.md +4 -1
  25. package/skills/council/SKILL.md +24 -45
  26. package/skills/delegation-patterns/SKILL.md +18 -1
  27. package/skills/distill-persona/SKILL.md +4 -1
  28. package/skills/distill-software/SKILL.md +4 -1
  29. package/skills/event-log-tracing/SKILL.md +4 -1
  30. package/skills/git-master/SKILL.md +4 -1
  31. package/skills/iterative-audit/SKILL.md +4 -1
  32. package/skills/live-agent-lifecycle/SKILL.md +4 -1
  33. package/skills/mailbox-interactive/SKILL.md +4 -1
  34. package/skills/model-routing-context/SKILL.md +10 -1
  35. package/skills/multi-perspective-review/SKILL.md +18 -1
  36. package/skills/observability-reliability/SKILL.md +4 -1
  37. package/skills/orchestration/SKILL.md +18 -1
  38. package/skills/ownership-session-security/SKILL.md +4 -1
  39. package/skills/pi-extension-lifecycle/SKILL.md +4 -1
  40. package/skills/post-mortem/SKILL.md +4 -1
  41. package/skills/read-only-explorer/SKILL.md +4 -1
  42. package/skills/real-test-pi-crew/SKILL.md +165 -12
  43. package/skills/requirements-to-task-packet/SKILL.md +10 -1
  44. package/skills/research/SKILL.md +4 -1
  45. package/skills/resource-discovery-config/SKILL.md +10 -1
  46. package/skills/runtime-state-reader/SKILL.md +4 -1
  47. package/skills/safe-bash/SKILL.md +4 -1
  48. package/skills/scrutinize/SKILL.md +24 -1
  49. package/skills/secure-agent-orchestration-review/SKILL.md +4 -1
  50. package/skills/state-mutation-locking/SKILL.md +4 -1
  51. package/skills/systematic-debugging/SKILL.md +4 -1
  52. package/skills/verification-before-done/SKILL.md +18 -1
  53. package/skills/widget-rendering/SKILL.md +4 -1
  54. package/skills/workspace-isolation/SKILL.md +4 -1
  55. package/skills/worktree-isolation/SKILL.md +4 -1
  56. package/src/config/config-validation.ts +1 -0
  57. package/src/config/types.ts +8 -0
  58. package/src/errors.ts +1 -1
  59. package/src/extension/context-status-injection.ts +2 -2
  60. package/src/extension/knowledge-injection.ts +19 -7
  61. package/src/extension/post-init-skill-check.ts +32 -0
  62. package/src/extension/register.ts +9 -1
  63. package/src/extension/registration/hook-registration.ts +20 -3
  64. package/src/extension/registration/tool-loop-guard.ts +243 -0
  65. package/src/extension/team-tool/handle-settings.ts +10 -0
  66. package/src/extension/team-tool/run.ts +42 -1
  67. package/src/extension/team-tool-types.ts +6 -0
  68. package/src/prompt/prompt-runtime.ts +25 -6
  69. package/src/runtime/async-runner.ts +75 -11
  70. package/src/runtime/background-runner.ts +73 -7
  71. package/src/runtime/broker/crew-broker-client.ts +45 -2
  72. package/src/runtime/broker/crew-broker.ts +22 -27
  73. package/src/runtime/broker/protocol/request-parsers.ts +10 -2
  74. package/src/runtime/broker/stdin-handshake.ts +87 -0
  75. package/src/runtime/broker/wait-push.ts +45 -0
  76. package/src/runtime/detached-run-results.ts +25 -1
  77. package/src/runtime/foreground-watchdog.ts +24 -5
  78. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  79. package/src/runtime/model/model-scope.ts +2 -2
  80. package/src/runtime/run-tracker.ts +74 -19
  81. package/src/runtime/skill-instructions.ts +20 -4
  82. package/src/runtime/task-runner/child-executor.ts +1 -1
  83. package/src/runtime/task-runner/prompt-builder.ts +22 -9
  84. package/src/schema/config-schema.ts +1 -0
  85. package/src/skills/discover-skills.ts +2 -2
  86. package/src/ui/settings-overlay.ts +40 -0
  87. package/src/utils/frontmatter.ts +7 -1
  88. package/src/utils/ndjson.ts +9 -1
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import * as path from "node:path";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { pathToFileURL } from "node:url";
6
6
  import { getCrewEnv } from "../config/env-vars.ts";
7
7
  import { appendEventAsync } from "../state/event-log/event-log.ts";
8
8
  import type { TeamRunManifest } from "../state/types.ts";
@@ -26,17 +26,14 @@ export type LoaderSpec = { kind: "jiti"; path: string } | { kind: "strip-types"
26
26
 
27
27
  type LoaderInput = LoaderSpec | string | false | undefined;
28
28
 
29
- function packageRootFromRuntime(): string {
30
- return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
31
- }
32
-
33
29
  function jitiRegisterPathFromPackageJson(packageJsonPath: string): string {
34
30
  return path.join(path.dirname(packageJsonPath), "lib", "jiti-register.mjs");
35
31
  }
36
32
 
37
- export function resolveJitiRegisterPath(packageRoot = packageRootFromRuntime(), exists: FileExists = fs.existsSync): string | undefined {
38
- // Walk upward from packageRoot looking for node_modules/jiti/lib/jiti-register.mjs
39
- let current = path.resolve(packageRoot);
33
+ export function resolveJitiRegisterPath(pkgRoot: string | undefined, exists: FileExists = fs.existsSync): string | undefined {
34
+ const effectiveRoot = pkgRoot ?? packageRoot();
35
+ // Walk upward from effectiveRoot looking for node_modules/jiti/lib/jiti-register.mjs
36
+ let current = path.resolve(effectiveRoot);
40
37
  const root = path.parse(current).root;
41
38
  while (true) {
42
39
  const candidate = path.join(current, "node_modules", "jiti", "lib", "jiti-register.mjs");
@@ -115,7 +112,7 @@ export function getBackgroundRunnerCommand(
115
112
  reportDirectory?: string,
116
113
  ): { args: string[]; loader: "jiti" | "strip-types" } {
117
114
  const loader = normalizeLoaderInput(loaderInput);
118
- if (!loader) throw new Error(buildLoaderUnavailableMessage(packageRootFromRuntime()));
115
+ if (!loader) throw new Error(buildLoaderUnavailableMessage(packageRoot()));
119
116
  // Limit V8 heap to 512MB for the background runner to avoid triggering the
120
117
  // Linux OOM killer. The runner itself is lightweight — it delegates work to
121
118
  // child Pi processes — so 512MB is generous. Without this limit, Node.js
@@ -256,6 +253,15 @@ export function buildBackgroundRunnerEnv(env: NodeJS.ProcessEnv): NodeJS.Process
256
253
  return { ...env, PI_CREW_ASYNC_RUN: "1" };
257
254
  }
258
255
 
256
+ /** F4 v2: the ONE line written to the background-runner's stdin carrying
257
+ * PER-TASK compound broker tokens (see stdin-handshake.ts for why compound —
258
+ * ADR-0 item 6 rejects bare-runId tokens for wait.*). Kept pure + exported so
259
+ * tests pin the WRITER format against the READER parser — a drift between
260
+ * the two silently breaks coordination for every async run. */
261
+ export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
262
+ return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
263
+ }
264
+
259
265
  export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise<SpawnBackgroundTeamRunResult> {
260
266
  // FIX (2026-07-02, perf review F-critical): use packageRoot() instead of
261
267
  // import.meta.url-relative path. The previous path.resolve walks
@@ -289,7 +295,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
289
295
 
290
296
  const loader = resolveTypeScriptLoader();
291
297
  if (!loader) {
292
- const message = buildLoaderUnavailableMessage(packageRootFromRuntime());
298
+ const message = buildLoaderUnavailableMessage(packageRoot());
293
299
  // FIX-08: use async event append to avoid sleepSync event-loop blocking.
294
300
  await appendEventAsync(manifest.eventsPath, {
295
301
  type: "async.failed",
@@ -318,11 +324,69 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
318
324
  cwd: manifest.cwd,
319
325
  detached: true,
320
326
  setsid: true,
321
- stdio: ["ignore", "pipe", "pipe"],
327
+ // F4 (2026-09-12 live battery): stdin is a PIPE — broker credentials
328
+ // travel heap → pipe → heap right after spawn (see below). Previously
329
+ // "ignore", which (together with the env allowlist and the missing
330
+ // runner-side issuer) left EVERY async worker broker-less: ask/message
331
+ // fell back to "proceed with best judgment" silently.
332
+ stdio: ["pipe", "pipe", "pipe"],
322
333
  env: childEnv,
323
334
  windowsHide: true,
324
335
  } as unknown as Parameters<typeof spawn>[2];
325
336
  const child = spawn(process.execPath, command.args, spawnOpts);
337
+ // F4: hand the runner a per-run broker credential over stdin. The env
338
+ // route is CLOSED BY DESIGN — BACKGROUND_RUNNER_ENV_ALLOWLIST cannot carry
339
+ // PI_CREW_BROKER_TOKEN (secret-suffixed names are rejected by the
340
+ // sanitizeEnvSecrets validator, and a PI_CREW_BROKER_* glob is flagged
341
+ // isDangerousGlob). The token never touches disk (invariant,
342
+ // lifecycle-handlers.ts:990) and dies with the parent session (heap-only
343
+ // registry). issuer(runId) without taskId mints the LEGACY per-run token,
344
+ // which the registry accepts for any task of the run via its bare-runId
345
+ // fallback (crew-broker-tokens.ts get()). Best-effort: any failure here
346
+ // leaves the runner creds-less (= previous behavior), never throws.
347
+ try {
348
+ let line: string;
349
+ // LAZY: broker issuer only when a handshake payload needs minting.
350
+ const { getActiveBrokerIssuer } = await import("./broker/broker-issuer.ts");
351
+ const issuer = getActiveBrokerIssuer();
352
+ if (issuer) {
353
+ // F4 v2: pre-mint a COMPOUND (runId+taskId) token for every task of the
354
+ // run — wait.* rejects bare-runId tokens (ADR-0 item 6), so the legacy
355
+ // issuer(runId) shortcut left every park forbidden. Tasks are persisted
356
+ // BEFORE dispatch (tasksPath exists on the manifest); dynamic workflows
357
+ // that plan tasks inside the runner get no creds (follow-up: broker-side
358
+ // mint RPC).
359
+ // LAZY: state-store pulls the whole stores chain into the async runner.
360
+ const { loadRunManifestByIdAsync } = await import("../state/stores/state-store.ts");
361
+ const loaded = await loadRunManifestByIdAsync(manifest.cwd, manifest.runId);
362
+ const runTasks = loaded?.tasks ?? [];
363
+ const tasks: Record<string, string> = {};
364
+ let socketPath: string | undefined;
365
+ for (const task of runTasks) {
366
+ if (!task?.id) continue;
367
+ const creds = await issuer(manifest.runId, task.id);
368
+ if (creds) {
369
+ tasks[task.id] = creds.token;
370
+ socketPath ??= creds.socketPath;
371
+ }
372
+ }
373
+ if (socketPath && Object.keys(tasks).length > 0) {
374
+ line = buildBrokerStdinLine(manifest.runId, socketPath, tasks);
375
+ child.stdin?.write(line);
376
+ } else {
377
+ child.stdin?.write("\n");
378
+ }
379
+ } else {
380
+ child.stdin?.write("\n");
381
+ }
382
+ } catch {
383
+ /* best-effort: runner proceeds creds-less */
384
+ }
385
+ try {
386
+ child.stdin?.end();
387
+ } catch {
388
+ /* EPIPE: runner already died — the spawn error handling below owns that */
389
+ }
326
390
  // Round 27 (BUG 3) history: the piped stdout/stderr were previously destroyed
327
391
  // immediately to avoid a pipe-buffer deadlock (child writes >64KB with nobody
328
392
  // draining → hang). BUT destroying stderr ALSO swallowed native crash
@@ -10,7 +10,6 @@ import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunSta
10
10
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
11
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
12
12
  import { errorMessage } from "../utils/guards.ts";
13
- import { projectCrewRoot } from "../utils/paths.ts";
14
13
  import { assertSafePathId } from "../utils/safe-paths.ts";
15
14
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
16
15
  // Heavy runtime — lazy-loaded to avoid pulling team-runner into background-runner
@@ -42,6 +41,10 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
42
41
 
43
42
  import { logInternalError } from "../utils/internal-error.ts";
44
43
  import { writeAsyncStartMarker } from "./async-marker.ts";
44
+ // F4: broker creds handshake helpers live in broker/stdin-handshake.ts
45
+ // (pure module — background-runner runs await main() at module scope, so
46
+ // importing THIS file from tests would boot the runner).
47
+ import { parseStdinBrokerPayload, readStdinFirstLine } from "./broker/stdin-handshake.ts";
45
48
  import { terminateActiveChildPiProcesses } from "./child-pi/child-pi.ts";
46
49
  import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
47
50
  import { resolveCrewRuntime, runtimeResolutionState } from "./model/runtime-resolver.ts";
@@ -160,6 +163,27 @@ export function signalEventType(sig: string): "async.signal" | "async.failed" {
160
163
  return BENIGN_SIGNALS.has(sig) ? "async.signal" : "async.failed";
161
164
  }
162
165
 
166
+ /**
167
+ * Scope-aware diagnostic paths for the background runner (issue #55, follows
168
+ * the #54 fix in run-tracker.ts). Runs created in a markerless (non-git) cwd
169
+ * live under userCrewRoot() — resolved via the same createRunPaths/
170
+ * scopeBaseRoot chain used at run CREATION — while project cwds keep .crew/ or
171
+ * the .pi/teams/ fallback (issue #29). Pure path math: createRunPaths asserts
172
+ * the runId (R11-2 boundary hardening preserved) and never touches the fs.
173
+ *
174
+ * These must stay (cwd, runId)-derived: both sites run BEFORE the manifest is
175
+ * loaded (the console redirect is the FIRST thing main() does; exitCodePath is
176
+ * computed at module load), so manifest.stateRoot is not available yet.
177
+ */
178
+ export function backgroundLogPath(cwd: string, runId: string): string {
179
+ return path.join(createRunPaths(cwd, runId).stateRoot, "background.log");
180
+ }
181
+
182
+ /** exit-code.txt sibling of background.log — same scope resolution (#55). */
183
+ export function backgroundExitCodePath(cwd: string, runId: string): string {
184
+ return path.join(createRunPaths(cwd, runId).stateRoot, "exit-code.txt");
185
+ }
186
+
163
187
  /**
164
188
  * Fire-and-forget event log for signal handlers. Extracted to module level
165
189
  * (from inside main()) so the exported SIGINT handler installer (test seam)
@@ -421,10 +445,12 @@ async function main(): Promise<void> {
421
445
  // best-effort try/catch so the hardening is never silently swallowed).
422
446
  assertSafePathId("runId", _runId);
423
447
  try {
424
- // Use projectCrewRoot() so the background log lives next to the
425
- // manifest in either .crew/state/runs/ or .pi/teams/state/runs/
426
- // depending on the project's chosen layout (issue #29).
427
- const logPath = path.join(projectCrewRoot(_cwd), "state", "runs", _runId, "background.log");
448
+ // Scope-aware (issue #55): resolve via createRunPaths so the log lands
449
+ // next to the manifest for BOTH project cwds (.crew/ or the .pi/teams/
450
+ // fallback, issue #29) and markerless cwds (user scope, issue #54). The
451
+ // previous projectCrewRoot join silently dropped the console redirect
452
+ // for user-scope runs — exactly the log needed to diagnose them.
453
+ const logPath = backgroundLogPath(_cwd, _runId);
428
454
  logFd = fs.openSync(logPath, "a");
429
455
  const origWrite =
430
456
  (_prefix: string) =>
@@ -478,8 +504,11 @@ async function main(): Promise<void> {
478
504
  // IIFE runs at MODULE LOAD, so an unsafe runId throws before the runner
479
505
  // starts (intended fail-fast, matching run-import.ts:105 pattern).
480
506
  assertSafePathId("runId", runId);
481
- // Use projectCrewRoot() to honour the .pi/teams/ fallback (issue #29).
482
- return path.join(projectCrewRoot(cwd), "state", "runs", runId, "exit-code.txt");
507
+ // Scope-aware (issue #55): same resolution as backgroundLogPath — project
508
+ // scope (.crew/ or .pi/teams/, issue #29) or user scope (#54). The
509
+ // previous projectCrewRoot join never landed exit-code.txt for user-scope
510
+ // runs, hiding non-zero exit diagnostics.
511
+ return backgroundExitCodePath(cwd, runId);
483
512
  })();
484
513
  if (exitCodePath) {
485
514
  process.on("exit", (code) => {
@@ -498,6 +527,43 @@ async function main(): Promise<void> {
498
527
  const cwd = argValue("--cwd");
499
528
  const runId = argValue("--run-id");
500
529
  if (!cwd || !runId) throw new Error("Usage: background-runner.ts --cwd <cwd> --run-id <runId>");
530
+ // F4 (2026-09-12 live battery): broker creds arrive on STDIN from the
531
+ // dispatching session (heap → pipe → heap; token never written to disk).
532
+ // Without this, every async worker loses ask/message/mailbox/steer — the
533
+ // env route is closed (allowlist rejects secret-suffixed tokens) and no
534
+ // extension lifecycle runs here to register an issuer. Best-effort:
535
+ // absent/invalid payload = creds-less runner = previous behavior.
536
+ try {
537
+ const raw = await readStdinFirstLine();
538
+ const payload = raw ? parseStdinBrokerPayload(raw, runId) : undefined;
539
+ if (payload) {
540
+ // LAZY: broker issuer module has process-level side effects at load.
541
+ const { setActiveBrokerIssuer } = await import("./broker/broker-issuer.ts");
542
+ // LAZY: pi-args pulls the model registry chain.
543
+ const { resolveCrewMaxDepth } = await import("./model/pi-args.ts");
544
+ // Static issuer scoped to THIS run, serving PRE-MINTED per-task COMPOUND
545
+ // tokens — wait.* rejects bare-runId tokens (ADR-0 item 6), so the v1
546
+ // single-token shortcut left every park forbidden. Unknown taskIds
547
+ // (dynamic workflows planned in-runner) get NO creds — follow-up:
548
+ // broker-side mint RPC. Depth-cap parity with the parent-side
549
+ // issueForChild gate (lifecycle-handlers.ts:1130-1136).
550
+ setActiveBrokerIssuer(async (rid, taskId, childDepth) => {
551
+ if (rid !== payload.runId) return undefined;
552
+ if (childDepth !== undefined && childDepth >= resolveCrewMaxDepth(undefined)) return undefined;
553
+ if (!taskId) return undefined;
554
+ const token = payload.tasks[taskId];
555
+ if (!token) return undefined;
556
+ return { socketPath: payload.socketPath, token };
557
+ });
558
+ debugLog(
559
+ `[broker] stdin handshake accepted for run ${runId} (${Object.keys(payload.tasks).length} task tokens, socket ${payload.socketPath})`,
560
+ );
561
+ } else {
562
+ debugLog(`[broker] no stdin creds payload — runner proceeds broker-less (pre-F4 behavior)`);
563
+ }
564
+ } catch {
565
+ /* best-effort: never fail boot over coordination creds */
566
+ }
501
567
  // FIX Issue #3: Wrap in withRunLockSync to prevent concurrent background-runners
502
568
  // for the same runId from reading stale manifest state. If lock cannot be
503
569
  // be acquired within 5s, fail immediately rather than proceeding with stale data.
@@ -42,6 +42,14 @@ const BROKER_PROTOCOL = 1;
42
42
  /** Per-attempt timeout for connect + hello. */
43
43
  const CONNECT_HELLO_TIMEOUT_MS = 5_000;
44
44
 
45
+ /** F5 (2026-09-12 live probe): default per-request RPC timeout. A response
46
+ * frame lost on a half-dead socket (no close event — observed live: worker
47
+ * stuck inside `await client.request("wait.request")` past its own ask
48
+ * deadline, because the deadline check lives AFTER the request resolves)
49
+ * would otherwise hang the caller forever. Callers with a longer natural
50
+ * cap (ask: timeoutSec) pass their own timeoutMs. */
51
+ const REQUEST_TIMEOUT_DEFAULT_MS = 15_000;
52
+
45
53
  /** Bounded backoff schedule (ms). At most 4 attempts means 3 retries after
46
54
  * the first failure. Jitter is ±25%. */
47
55
  const BACKOFF_SCHEDULE_MS: readonly number[] = [50, 100, 200, 400, 800] as const;
@@ -83,6 +91,8 @@ interface PendingRequest {
83
91
  method: string;
84
92
  resolve: (value: unknown) => void;
85
93
  reject: (err: Error) => void;
94
+ /** F5: per-request timeout timer; cleared on settle + close. */
95
+ timer?: NodeJS.Timeout;
86
96
  }
87
97
 
88
98
  export class CrewBrokerClient {
@@ -153,7 +163,7 @@ export class CrewBrokerClient {
153
163
  * Never throws. The caller can continue using file-based fallback paths
154
164
  * without unwrapping anything.
155
165
  */
156
- async request<T = unknown>(method: string, params: unknown): Promise<BrokerClientResult<T>> {
166
+ async request<T = unknown>(method: string, params: unknown, opts?: { timeoutMs?: number }): Promise<BrokerClientResult<T>> {
157
167
  if (this._mode === "fallback") {
158
168
  return { ok: false, fallback: true, errorCode: "fallback-sticky" };
159
169
  }
@@ -176,9 +186,26 @@ export class CrewBrokerClient {
176
186
  // Send the request. Send a frame FIRST so the server's hello gate
177
187
  // cannot reject it as "method other than hello".
178
188
  const id = `r-${randomUUID()}`;
189
+ let entry: PendingRequest | undefined;
179
190
  const promise = new Promise<unknown>((resolve, reject) => {
180
- this.pending.set(id, { id, method, resolve, reject });
191
+ entry = { id, method, resolve, reject };
192
+ this.pending.set(id, entry);
181
193
  });
194
+ // F5: arm the per-request timeout BEFORE the write — a frame lost on a
195
+ // half-dead socket produces neither a response nor a close, and the
196
+ // caller would hang forever (observed live on a parked ask worker).
197
+ // Rejecting the pending entry funnels into the existing catch below
198
+ // (typed errorCode + enterFallbackOnce), so no new code path is needed.
199
+ const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT_DEFAULT_MS;
200
+ const requestTimer: NodeJS.Timeout = (this.options.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms)))(
201
+ () => {
202
+ const pendingEntry = this.pending.get(id);
203
+ this.pending.delete(id);
204
+ pendingEntry?.reject(new BrokerError("request-timeout", `no response for ${method} within ${timeoutMs}ms`));
205
+ },
206
+ Math.max(1, Math.floor(timeoutMs)),
207
+ );
208
+ if (entry) entry.timer = requestTimer;
182
209
  try {
183
210
  const frame = encodeBrokerFrame({ id, method, params });
184
211
  // Write may emit EPIPE etc. We don't await drain here — the response
@@ -206,6 +233,15 @@ export class CrewBrokerClient {
206
233
  const code = err instanceof BrokerError ? err.code : "request-failed";
207
234
  this.enterFallbackOnce(code, err);
208
235
  return { ok: false, fallback: true, errorCode: code };
236
+ } finally {
237
+ // F5: disarm the per-request timeout on ANY settle path (response,
238
+ // broker-error envelope, socket rejection) so late timer fires cannot
239
+ // reject an already-consumed pending entry.
240
+ try {
241
+ (this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t)))(requestTimer);
242
+ } catch {
243
+ /* best-effort */
244
+ }
209
245
  }
210
246
  }
211
247
 
@@ -278,6 +314,13 @@ export class CrewBrokerClient {
278
314
  // Resolving with undefined would have made request() return
279
315
  // {ok:true, value:undefined}, which is misleading.
280
316
  for (const [, p] of this.pending) {
317
+ if (p.timer) {
318
+ try {
319
+ (this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t)))(p.timer);
320
+ } catch {
321
+ /* best-effort */
322
+ }
323
+ }
281
324
  p.reject(new BrokerError("close", "client closed"));
282
325
  }
283
326
  this.pending.clear();
@@ -58,6 +58,7 @@ import {
58
58
  WAIT_REQUEST_TIMEOUT_SEC_MAX,
59
59
  } from "./protocol/request-parsers.ts";
60
60
  import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
61
+ import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
61
62
  import { WaitStatusCache } from "./wait-status-cache.ts";
62
63
 
63
64
  /** Protocol version negotiated at `hello` time. Bump on breaking change.
@@ -1194,22 +1195,13 @@ export class CrewBroker {
1194
1195
  await pollUntilDone();
1195
1196
  }
1196
1197
 
1197
- /**
1198
- * Phase 3: steer.push — push steering message to a running worker.
1199
- *
1200
- * Dual-write strategy for durability:
1201
- * 1. Mailbox append (appendMailboxMessageAsync) — feeds the live broker
1202
- * fanout to connected subscribers AND persists to the mailbox inbox
1203
- * JSONL for later read.
1204
- * 2. Steering-file append — writes the steer body to
1205
- * ${artifactsRoot}/steering/${taskId}.jsonl, the same file the
1206
- * child's pollSteering() polls via PI_CREW_STEERING_FILE. This is
1207
- * the durable fallback: even if the recipient child's broker connection is down, the
1208
- * child picks up the steer on its next poll tick.
1209
- *
1210
- * A steering-file write failure does NOT fail the steer push — the
1211
- * mailbox write (1) has already succeeded.
1212
- */
1198
+ /** Phase 3: steer.push — push steering message to a running worker.
1199
+ * Dual-write for durability: (1) mailbox append feeds the live broker
1200
+ * fanout AND persists to the inbox JSONL; (2) steering-file append writes
1201
+ * ${artifactsRoot}/steering/${taskId}.jsonl — the durable fallback the
1202
+ * child's pollSteering() polls via PI_CREW_STEERING_FILE even when its
1203
+ * broker connection is down. A steering-file write failure does NOT fail
1204
+ * the push — the mailbox write has already succeeded. */
1213
1205
  private async handleSteerPush(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1214
1206
  if (conn.role !== "orchestrator") {
1215
1207
  this.sendError(conn, id, "forbidden", "steer.push requires orchestrator role");
@@ -1873,6 +1865,17 @@ export class CrewBroker {
1873
1865
  timeoutSec: clampSec,
1874
1866
  clamped,
1875
1867
  });
1868
+ // F1 (2026-09-12 live battery): release the sync foreground waiter with
1869
+ // the question (evidence + design notes in broker/wait-push.ts).
1870
+ await pushWaitingToForegroundWaiter({
1871
+ cwd: this.options.cwd,
1872
+ runId,
1873
+ taskId,
1874
+ questionId,
1875
+ question: parsed.question,
1876
+ deadline,
1877
+ ...(parsed.options ? { options: parsed.options } : {}),
1878
+ });
1876
1879
  }
1877
1880
 
1878
1881
  /** WP-2/R2: terminal report of the parked `ask` tool — flips the task
@@ -1986,14 +1989,6 @@ export class CrewBroker {
1986
1989
  // Type guards (no `any`)
1987
1990
  // ============================================================================
1988
1991
 
1989
- /** Moved to ./protocol/request-parsers.ts (M4 / WI-4.1):
1990
- * - isRequestObject
1991
- * - isHelloParams + BROKER_PROTOCOL
1992
- * - parseMsgSendParams + MsgSendParams
1993
- * - parseMsgInboxParams + MsgInboxParams
1994
- * - parseWaitRequestParams + WaitRequestParams
1995
- * - parseWaitResolveParams + WaitResolveParams
1996
- * - safeStringify
1997
- * - WAIT_* constants
1998
- * Removed from this file; re-exported via "./protocol/request-parsers.ts".
1999
- */
1992
+ /** Parsers/constants moved to ./protocol/request-parsers.ts (M4 / WI-4.1):
1993
+ * hello/msg/wait params + safeStringify + WAIT_* constants — re-exported
1994
+ * from there. */
@@ -118,8 +118,16 @@ export function safeStringify(value: unknown): string {
118
118
  * timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
119
119
  * amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
120
120
  export const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
121
- /** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
122
- export const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
121
+ /** Default ask timeout when the caller omits timeoutSec (ADR item 1).
122
+ * F2 (2026-09-12 live battery): MUST stay strictly below the worker response
123
+ * watchdog (`DEFAULT_CHILD_PI.responseTimeoutMs` = 600s). A parked worker
124
+ * emits NO output, so the watchdog counts the whole park; at 600==600 the
125
+ * kill raced the wake and the worker died at its own deadline
126
+ * (team_20260912014448: parked 01:46:01, response_timeout 01:56:01). 480s
127
+ * leaves 120s grace for the worker to wake, answer its fallback, and finish
128
+ * the turn. MAX stays 3600 (ADR P2-7) — a park longer than ~600s needs
129
+ * PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS raised to survive the watchdog. */
130
+ export const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 480;
123
131
  /** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
124
132
  export const WAIT_QUESTION_MAX_CHARS = 8192;
125
133
  /** Bounded answer-choice list: at most 16 options, 256 chars each. */
@@ -0,0 +1,87 @@
1
+ /**
2
+ * stdin-handshake.ts — F4 (2026-09-12 live battery): broker credentials for
3
+ * the DETACHED background runner travel on STDIN (heap → pipe → heap).
4
+ *
5
+ * Why stdin: the env route is CLOSED BY DESIGN — BACKGROUND_RUNNER_ENV_ALLOWLIST
6
+ * cannot carry PI_CREW_BROKER_TOKEN (secret-suffixed names are rejected by the
7
+ * sanitizeEnvSecrets validator, and a PI_CREW_BROKER_* glob is flagged
8
+ * isDangerousGlob), and the token must NEVER be written to disk (invariant,
9
+ * lifecycle-handlers.ts:990). Without this handshake every async-run worker
10
+ * loses ask/message/mailbox/steer coordination and silently falls back to
11
+ * "proceed with best judgment".
12
+ *
13
+ * Pure protocol pieces live here (not in background-runner.ts, which runs
14
+ * `await main()` at module scope) so tests can import them without booting
15
+ * the runner.
16
+ */
17
+
18
+ /** The one-line payload async-runner writes to the runner's stdin carrying
19
+ * PER-TASK compound tokens. v2 (ADR-0 2026-08-17 item 6): wait.* accepts
20
+ * task-scoped (compound) tokens ONLY — the legacy bare-runId token
21
+ * authenticates the connection but waitAuthError rejects its parks with
22
+ * `forbidden`. The dispatching session pre-mints a compound token for every
23
+ * task in the manifest (they exist before dispatch); dynamic-workflow tasks
24
+ * planned inside the runner get NO creds (follow-up: broker mint RPC). */
25
+ export interface StdinBrokerPayload {
26
+ v: 2;
27
+ runId: string;
28
+ socketPath: string;
29
+ tasks: Record<string, string>;
30
+ }
31
+
32
+ /** Parse + validate one handshake line. Rejects wrong version, wrong run
33
+ * (cross-run containment), and missing/empty fields. Pure. */
34
+ export function parseStdinBrokerPayload(raw: string, expectedRunId: string): StdinBrokerPayload | undefined {
35
+ try {
36
+ const obj: unknown = JSON.parse(raw.trim());
37
+ if (!obj || typeof obj !== "object") return undefined;
38
+ const o = obj as Record<string, unknown>;
39
+ if (o.v !== 2) return undefined;
40
+ if (o.runId !== expectedRunId) return undefined;
41
+ if (typeof o.socketPath !== "string" || o.socketPath.length === 0) return undefined;
42
+ const tasks = o.tasks;
43
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) return undefined;
44
+ for (const [id, tok] of Object.entries(tasks)) {
45
+ if (id.length === 0 || typeof tok !== "string" || tok.length === 0) return undefined;
46
+ }
47
+ return { v: 2, runId: o.runId, socketPath: o.socketPath, tasks: tasks as Record<string, string> };
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** Read the FIRST stdin line with a hard cap. Resolves undefined for: no
54
+ * stdin / TTY / silence past the timeout / oversize / error. Never throws,
55
+ * never destroys stdin (leaves it paused for the rest of boot). */
56
+ export function readStdinFirstLine(timeoutMs = 2000): Promise<string | undefined> {
57
+ return new Promise((resolve) => {
58
+ const stdin = process.stdin;
59
+ if (!stdin || stdin.isTTY || stdin.destroyed || !stdin.readable) {
60
+ resolve(undefined);
61
+ return;
62
+ }
63
+ let settled = false;
64
+ const finish = (value: string | undefined): void => {
65
+ if (settled) return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ stdin.removeListener("data", onData);
69
+ resolve(value);
70
+ };
71
+ const timer = setTimeout(() => finish(undefined), timeoutMs);
72
+ let buf = "";
73
+ const onData = (chunk: Buffer | string): void => {
74
+ buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
75
+ const nl = buf.indexOf("\n");
76
+ if (nl >= 0) {
77
+ stdin.pause();
78
+ finish(buf.slice(0, nl));
79
+ } else if (buf.length > 64 * 1024) {
80
+ finish(undefined);
81
+ }
82
+ };
83
+ stdin.on("data", onData);
84
+ stdin.on("end", () => finish(buf.length > 0 ? buf : undefined));
85
+ stdin.on("error", () => finish(undefined));
86
+ });
87
+ }
@@ -0,0 +1,45 @@
1
+ import { loadRunManifestById } from "../../state/stores/state-store.ts";
2
+
3
+ /** F1 (2026-09-12 live battery): release the sync foreground waiter with the
4
+ * parked question. Without this push, the only entity that can answer (the
5
+ * leader LLM) stays suspended inside its own `team` tool call until the
6
+ * response watchdog kills the parked worker (evidence: team_20260912014448 —
7
+ * parked 01:46:01, response_timeout 01:56:01, tool call returned only after
8
+ * the failure; refuted-fix round 2 evidence: team_20260912053049).
9
+ *
10
+ * resolveRunPromise is a no-op when no waiter is registered (async/detached
11
+ * runs poll instead). Best-effort by design: a failure here must not fail
12
+ * the park itself. Kept in its own module (LAZY-imported from the broker) so
13
+ * crew-broker.ts stays under the M4 2000-line gate and no static
14
+ * broker→run-tracker edge exists at module load (there is no cycle —
15
+ * run-tracker has no broker import). */
16
+ export async function pushWaitingToForegroundWaiter(params: {
17
+ cwd: string | undefined;
18
+ runId: string;
19
+ taskId: string;
20
+ questionId: string;
21
+ question: string;
22
+ deadline: number;
23
+ options?: string[];
24
+ }): Promise<void> {
25
+ try {
26
+ // LAZY: run-tracker import kept lazy — see the module doc above.
27
+ const { resolveRunPromise } = await import("../run-tracker.ts");
28
+ const freshPark = loadRunManifestById(params.cwd ?? process.cwd(), params.runId);
29
+ if (freshPark) {
30
+ resolveRunPromise(params.runId, {
31
+ manifest: freshPark.manifest,
32
+ tasks: freshPark.tasks,
33
+ waiting: {
34
+ taskId: params.taskId,
35
+ questionId: params.questionId,
36
+ question: params.question,
37
+ deadline: params.deadline,
38
+ ...(params.options ? { options: params.options } : {}),
39
+ },
40
+ });
41
+ }
42
+ } catch {
43
+ /* best-effort push: a failure here must not fail the park itself */
44
+ }
45
+ }
@@ -11,21 +11,31 @@
11
11
  * The registry is in-process; a pending result is parked while an agent view is
12
12
  * open so the worker's own view session never receives the parent's report.
13
13
  */
14
+
14
15
  import { loadRunManifestById } from "../state/stores/state-store.ts";
15
16
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
17
+ import { logInternalError } from "../utils/internal-error.ts";
16
18
  import { isFinishedRunStatus } from "./process-status.ts";
17
19
 
18
20
  interface DetachedRun {
19
21
  runId: string;
20
22
  cwd: string;
23
+ /** Delivery attempts (P2-7). Each peek of a finished run is one attempt;
24
+ * a successful send forgets the entry, so the counter only accumulates on
25
+ * repeated send failures. Bounded at MAX_DELIVERY_ATTEMPTS to stop an
26
+ * indefinitely-failing send from retrying every tick forever. */
27
+ attempts: number;
21
28
  }
22
29
 
30
+ /** P2-7: give up after this many delivery attempts and log the drop. */
31
+ const MAX_DELIVERY_ATTEMPTS = 3;
32
+
23
33
  const detachedRuns = new Map<string, DetachedRun>();
24
34
 
25
35
  /** Record a run whose foreground waiter was released by a view switch. */
26
36
  export function markRunDetached(runId: string, cwd: string): void {
27
37
  if (!runId || !cwd) return;
28
- detachedRuns.set(runId, { runId, cwd });
38
+ detachedRuns.set(runId, { runId, cwd, attempts: 0 });
29
39
  }
30
40
 
31
41
  /** Cheap guard for hot paths (render tick): nothing to do when empty. */
@@ -84,6 +94,20 @@ export function peekFinishedDetachedRunResults(options: { inViewSession?: boolea
84
94
  continue;
85
95
  }
86
96
  if (!isFinishedRunStatus(loaded.manifest.status)) continue;
97
+ // P2-7: bounded delivery — drop after MAX_DELIVERY_ATTEMPTS failed
98
+ // cycles (a successful send forgets the entry, so reaching here again
99
+ // means the previous send threw).
100
+ entry.attempts += 1;
101
+ if (entry.attempts > MAX_DELIVERY_ATTEMPTS) {
102
+ detachedRuns.delete(entry.runId);
103
+ logInternalError(
104
+ "detached-run-results.delivery-gave-up",
105
+ new Error("delivery attempts exceeded"),
106
+ `runId=${entry.runId} attempts=${entry.attempts} — dropped after ${MAX_DELIVERY_ATTEMPTS} failed sends`,
107
+ "warn",
108
+ );
109
+ continue;
110
+ }
87
111
  ready.push({ runId: entry.runId, text: formatDetachedRunResult(loaded.manifest, loaded.tasks) });
88
112
  }
89
113
  return ready;