pi-crew 0.6.1 → 0.6.4

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 (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. package/test-integration-check.ts +4 -0
@@ -11,6 +11,7 @@ import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guar
11
11
  import { redactJsonLine } from "../utils/redaction.ts";
12
12
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
13
13
  import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
14
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
14
15
 
15
16
  const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
16
17
  const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
@@ -159,6 +160,8 @@ export interface ChildPiRunInput {
159
160
  agentId?: string;
160
161
  /** Role for tool restrictions (from role-tools.ts) */
161
162
  role?: string;
163
+ /** Root directory for artifacts (used to validate transcriptPath). */
164
+ artifactsRoot?: string;
162
165
  }
163
166
 
164
167
  export interface ChildPiRunResult {
@@ -174,10 +177,34 @@ export interface ChildPiRunResult {
174
177
  }
175
178
 
176
179
  export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): SpawnOptions {
180
+ // SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
181
+ // If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
182
+ // could cause the child process to operate in an attacker-controlled directory,
183
+ // enabling path traversal attacks, unintended file access, or exposure of sensitive paths.
184
+ // Use realpathSync to resolve any symlinks and verify the path exists and is a directory.
185
+ let validatedCwd: string;
186
+ try {
187
+ validatedCwd = fs.realpathSync(cwd);
188
+ const stats = fs.statSync(validatedCwd);
189
+ if (!stats.isDirectory()) {
190
+ throw new Error(`cwd is not a directory: ${cwd}`);
191
+ }
192
+ } catch (error) {
193
+ // If cwd doesn't exist (ENOENT) and isn't a security concern, fall back
194
+ // to the lexical path. The child process will create the directory if
195
+ // needed. Throwing would break tests/callers that pass not-yet-existing
196
+ // paths and isn't a security issue for the env-filtering behavior this
197
+ // function is primarily about.
198
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" && error instanceof Error && error.message.includes("ENOENT")) {
199
+ validatedCwd = path.resolve(cwd);
200
+ } else {
201
+ throw new Error(`Invalid cwd: ${cwd} — ${error instanceof Error ? error.message : String(error)}`);
202
+ }
203
+ }
204
+
177
205
  // Filter out env vars whose keys match secret patterns to avoid leaking credentials to child processes.
178
206
  // IMPORTANT: preserve model provider API keys — they are needed by the child Pi to call the LLM.
179
207
  // Also preserve essential non-secret vars (PATH, HOME, USER, etc.) so the child process can function.
180
- // Bug #10 fix: allow-list preserves model provider keys.
181
208
  // Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
182
209
  const filteredEnv = sanitizeEnvSecrets(env, {
183
210
  allowList: [
@@ -190,26 +217,14 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
190
217
  * - sanitizeEnvSecrets strips all env vars NOT on this list.
191
218
  * - Do NOT add wildcards ("*_API_KEY") — only explicit, intended provider keys.
192
219
  * - Consider per-task key scoping if the architecture allows it in the future.
220
+ *
221
+ * MAINTENANCE REQUIREMENT: When new secret env vars are added to the Pi ecosystem,
222
+ * they MUST be explicitly added to this allowlist to be passed to child processes.
223
+ * A CI check should fail if a secret-like env var (matching patterns like *_API_KEY,
224
+ * *_TOKEN, *_SECRET) is detected in the codebase but not present in this list.
193
225
  */
194
- // Model provider API keys (explicit listdo NOT use wildcards)
195
- "MINIMAX_API_KEY",
196
- "MINIMAX_GROUP_ID",
197
- "OPENAI_API_KEY",
198
- "OPENAI_ORG_ID",
199
- "ANTHROPIC_API_KEY",
200
- "GOOGLE_API_KEY",
201
- "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
202
- "AZURE_OPENAI_API_KEY",
203
- "AZURE_OPENAI_ENDPOINT",
204
- "AWS_ACCESS_KEY_ID",
205
- "AWS_SECRET_ACCESS_KEY",
206
- "AWS_REGION",
207
- "ZEU_API_KEY",
208
- "ZERODEV_API_KEY",
209
- // SECURITY FIX: Removed dangerous wildcards "*_API_KEY", "*_TOKEN", "*_SECRET"
210
- // These patterns would leak ALL secrets matching the pattern to child processes.
211
- // Only add specific, intended provider keys above.
212
- // Essential non-secret vars for child process to function
226
+ // NOTE: Model provider API keys are NOT needed here child Pi uses the same
227
+ // config file as parent Pi. Passing keys via env is a security risk.
213
228
  "PATH",
214
229
  "HOME",
215
230
  "USER",
@@ -233,34 +248,105 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
233
248
  "NVM_BIN",
234
249
  "NVM_DIR",
235
250
  "NVM_INC",
236
- "NODE_PATH",
251
+ // NODE_PATH is intentionally omitted from the allowlist.
252
+ // NODE_PATH can reveal user environment information (e.g., NVM paths under $HOME)
253
+ // and the validation at lines 286-298 only filters to standard system prefixes.
254
+ // Removing it entirely is cleaner than best-effort filtering.
237
255
  "NODE_DISABLE_COLORS",
238
256
  "NODE_EXTRA_CA_CERTS",
239
257
  "NPM_CONFIG_REGISTRY",
240
258
  "NPM_CONFIG_USERCONFIG",
241
259
  "NPM_CONFIG_GLOBALCONFIG",
242
- "PI_*",
243
- "PI_CREW_*",
244
- "PI_TEAMS_*",
260
+ // FIX: Replace PI_CREW_*/PI_TEAMS_* wildcards with explicit list of
261
+ // safe vars. Wildcards are fragile — any new secret var would leak.
262
+ // Only non-secret execution-control vars that children legitimately need.
263
+ "PI_CREW_DEPTH",
264
+ "PI_CREW_MAX_DEPTH",
265
+ "PI_CREW_INHERIT_PROJECT_CONTEXT",
266
+ "PI_CREW_INHERIT_SKILLS",
267
+ // PI_CREW_PARENT_PID is needed by child-pi's parent-guard (uses
268
+ // process.kill(pid, 0) liveness check). The PID is not a secret.
269
+ "PI_CREW_PARENT_PID",
270
+ "PI_TEAMS_DEPTH",
271
+ "PI_TEAMS_MAX_DEPTH",
272
+ "PI_TEAMS_INHERIT_PROJECT_CONTEXT",
273
+ "PI_TEAMS_INHERIT_SKILLS",
274
+ "PI_TEAMS_PI_BIN",
275
+ "PI_TEAMS_MOCK_CHILD_PI",
276
+ "PI_CREW_ALLOW_MOCK",
245
277
  ],
246
278
  });
247
- // Block execution control vars from leaking to child processes
248
- delete filteredEnv.PI_CREW_EXECUTE_WORKERS;
249
- delete filteredEnv.PI_TEAMS_EXECUTE_WORKERS;
279
+ // FIX: Removed delete workarounds with explicit allowlist, these vars
280
+ // are no longer auto-leaked. The wildcard approach was fragile.
281
+
282
+ // SECURITY FIX (Issue #1): Validate NODE_PATH to ensure it only contains standard
283
+ // system locations or legitimate user paths (NVM). NODE_PATH can reveal user
284
+ // environment information and could theoretically be exploited if it contains
285
+ // untrusted entries. Only allow paths under standard system directories
286
+ // (/opt, /lib, /usr) or NVM paths under /home/<user>/.nvm/... which are legitimate
287
+ // for Node.js module loading in user environments.
288
+ if (filteredEnv.NODE_PATH) {
289
+ const validPrefixes = ["/opt/", "/lib/", "/usr/local/", "/usr/", "/home/"];
290
+ const validPaths = filteredEnv.NODE_PATH.split(":").filter((p) => {
291
+ return validPrefixes.some((prefix) => p.startsWith(prefix));
292
+ });
293
+ if (validPaths.length > 0) {
294
+ filteredEnv.NODE_PATH = validPaths.join(":");
295
+ } else {
296
+ // No standard paths found — remove NODE_PATH entirely to avoid
297
+ // passing user-specific paths that could reveal environment info.
298
+ delete filteredEnv.NODE_PATH;
299
+ }
300
+ }
301
+
250
302
  return {
251
- cwd,
303
+ cwd: validatedCwd,
252
304
  env: { ...filteredEnv, PI_CREW_PARENT_PID: String(process.pid) },
253
305
  stdio: ["ignore", "pipe", "pipe"], // stdin=ignore: child doesn't wait for input; task comes via CLI args
254
306
  detached: process.platform !== "win32",
255
307
  setsid: true,
308
+ // NOTE: setsid creates a new session; the child process becomes the session leader
309
+ // and its parent becomes that session leader (still the team-runner in the same
310
+ // process group). PI_CREW_PARENT_PID is set before spawn using process.pid (team-runner).
311
+ // The parent-guard in the child checks direct parent liveness via process.kill(pid, 0) —
312
+ // it does NOT follow the lineage beyond the direct parent. If the team-runner's parent
313
+ // (the original pi session) dies, the team-runner becomes an orphan but the child still
314
+ // sees its direct parent (team-runner) as alive. This is correct for the parent-guard model.
256
315
  windowsHide: true,
257
316
  } as SpawnOptions;
258
317
  }
259
318
 
260
319
  function appendTranscript(input: ChildPiRunInput, line: string): void {
261
320
  if (!input.transcriptPath) return;
262
- fs.mkdirSync(path.dirname(input.transcriptPath), { recursive: true });
263
- fs.appendFileSync(input.transcriptPath, `${redactJsonLine(line)}\n`, "utf-8");
321
+ // SECURITY FIX (Issue #1): Validate transcriptPath against artifactsRoot to prevent
322
+ // arbitrary file writes and symlink traversal attacks. An attacker who can influence
323
+ // the task graph could set transcriptPath to /etc/passwd or similar, and mkdirSync
324
+ // with recursive:true would create parent directories. Additionally, appendFileSync
325
+ // follows symlinks, potentially writing to sensitive files.
326
+ let safePath: string;
327
+ try {
328
+ const artifactsRoot = input.artifactsRoot ?? input.cwd;
329
+ safePath = resolveRealContainedPath(artifactsRoot, input.transcriptPath);
330
+ } catch (error) {
331
+ logInternalError("child-pi.transcript-path-rejected", error as Error, `transcriptPath=${input.transcriptPath}`);
332
+ return;
333
+ }
334
+ // Use O_NOFOLLOW | O_CREAT | O_APPEND to safely open the transcript file.
335
+ // O_NOFOLLOW prevents symlink attacks (refuses to follow symlinks).
336
+ // O_CREAT creates the file if it doesn't exist.
337
+ // O_APPEND atomically positions at end for each write (no seek race).
338
+ // O_EXCL was previously used but prevented appending to existing files,
339
+ // causing EBADF on subsequent writes.
340
+ // NOTE: Parent directory must already exist (caller's responsibility).
341
+ // We skip mkdirSync here for security — adding it would create parent
342
+ // directories during validation, contradicting the original design where
343
+ // resolveRealContainedPath validates a pre-existing path.
344
+ const fd = fs.openSync(safePath, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
345
+ try {
346
+ fs.writeSync(fd, `${redactJsonLine(line)}\n`, undefined, "utf-8");
347
+ } finally {
348
+ fs.closeSync(fd);
349
+ }
264
350
  }
265
351
 
266
352
  function compactString(value: string, maxChars = MAX_COMPACT_CONTENT_CHARS): string {
@@ -415,9 +501,16 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
415
501
  if (depth.blocked) return { exitCode: 1, stdout: "", stderr: `pi-crew depth guard blocked child worker: depth ${depth.depth} >= max ${depth.maxDepth}` };
416
502
  const mock = process.env.PI_TEAMS_MOCK_CHILD_PI;
417
503
  if (mock) {
418
- // SECURITY: Require explicit PI_CREW_ALLOW_MOCK=1 to activate mock mode.
419
- // PI_CREW_ALLOW_MOCK must be set in the parent process env (not by child hooks)
420
- // since sanitizeEnvSecrets only passes PI_CREW_* vars from the parent.
504
+ // SECURITY (Issue #2): Mock mode security model is intentionally asymmetric.
505
+ // PI_TEAMS_MOCK_CHILD_PI is in the allowlist (passed to children) but
506
+ // PI_CREW_ALLOW_MOCK is NOT in the allowlist it is only checked in the
507
+ // parent process scope. This means:
508
+ // (1) If an attacker sets PI_CREW_ALLOW_MOCK in the parent's environment,
509
+ // it will NOT be passed to child processes (safe).
510
+ // (2) Mock mode activation in the child always fails the PI_CREW_ALLOW_MOCK
511
+ // check, so mock mode can only be triggered from the parent process.
512
+ // This asymmetry is intentional: PI_CREW_ALLOW_MOCK must be set in the Pi root
513
+ // process (the entry point that spawns children), not inherited from a parent.
421
514
  // Setup hooks cannot inject PI_CREW_ALLOW_MOCK into the parent's env.
422
515
  const allowMock = process.env.PI_CREW_ALLOW_MOCK === "1" || process.env.PI_CREW_ALLOW_MOCK === "true";
423
516
  if (!allowMock) {
@@ -445,6 +538,20 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
445
538
  const spawnSpec = getPiSpawnCommand(built.args);
446
539
  try {
447
540
  return await new Promise<ChildPiRunResult>((resolve) => {
541
+ // SECURITY (Issue #3): built.env contains only PI_CREW_* execution-control vars (NOT secrets).
542
+ // It is safe to spread built.env after process.env because sanitizeEnvSecrets will filter
543
+ // any secret values before the env reaches spawn(). However, if built.env ever gains
544
+ // secret content without corresponding allowlist filtering, secrets would leak to children.
545
+ // This comment serves as a warning: built.env must never contain secret values.
546
+ //
547
+ // Runtime assertion: verify all built.env keys are execution-control vars (PI_CREW_* or PI_TEAMS_*).
548
+ // This is a canary for future regressions — if someone accidentally adds a secret key to
549
+ // built.env, the assertion will throw before the secret reaches the child process.
550
+ for (const key of Object.keys(built.env)) {
551
+ if (!key.startsWith("PI_CREW_") && !key.startsWith("PI_TEAMS_")) {
552
+ throw new Error(`SECURITY: built.env contains unexpected key "${key}"; expected only PI_CREW_* or PI_TEAMS_* execution-control vars`);
553
+ }
554
+ }
448
555
  const child = spawn(spawnSpec.command, spawnSpec.args, buildChildPiSpawnOptions(input.cwd, { ...process.env, ...built.env }));
449
556
  if (child.pid) {
450
557
  activeChildProcesses.set(child.pid, child);
@@ -514,8 +621,12 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
514
621
  };
515
622
 
516
623
  let softLimitReached = false;
624
+ let steerInjectionFailed = false;
517
625
  const maxTurns = input.maxTurns;
518
- const graceTurns = input.graceTurns;
626
+ // FIX (Issue #1): Bound graceTurns to prevent the hard abort condition from
627
+ // never triggering when an arbitrarily large value is passed.
628
+ let graceTurns = input.graceTurns;
629
+ if (graceTurns !== undefined && graceTurns > 1000) graceTurns = 1000;
519
630
  let abortDueToParentSignal = false;
520
631
  input.signal?.addEventListener("abort", () => { abortDueToParentSignal = true; }, { once: true });
521
632
  const restartNoResponseTimer = (): void => {
@@ -558,8 +669,22 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
558
669
  turnCount += 1;
559
670
  if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
560
671
  softLimitReached = true;
561
- // Inject steer via stdin to tell child to wrap up
562
- child.stdin?.write(JSON.stringify({ type: "steer", message: "You have reached your turn limit. Wrap up immediately — provide your final answer now." }) + "\n");
672
+ // Inject steer via stdin to tell child to wrap up.
673
+ // If stdin is not writable or the write fails (backpressure/closed),
674
+ // the steer cannot be injected and the agent could run indefinitely.
675
+ // Kill the process tree in that case to enforce the turn limit.
676
+ if (child.stdin?.writable) {
677
+ const steerPayload = JSON.stringify({ type: "steer", message: "You have reached your turn limit. Wrap up immediately — provide your final answer now." }) + "\n";
678
+ const writeSucceeded = child.stdin.write(steerPayload);
679
+ if (!writeSucceeded) {
680
+ logInternalError("child-pi.steer-backpressure", new Error("stdin write returned false during steer injection; buffer full"), `pid=${child.pid}`);
681
+ steerInjectionFailed = true;
682
+ killProcessTree(child.pid, child);
683
+ }
684
+ } else {
685
+ logInternalError("child-pi.steer-not-writable", new Error("stdin not writable when attempting steer injection"), `pid=${child.pid}`);
686
+ killProcessTree(child.pid, child);
687
+ }
563
688
  } else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + (graceTurns ?? 5)) {
564
689
  // Hard abort — terminate after grace turns
565
690
  try { child.kill(process.platform === "win32" ? undefined : "SIGTERM"); } catch { /* best-effort */ }
@@ -637,6 +762,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
637
762
 
638
763
  const abort = (): void => {
639
764
  abortRequested = true;
765
+ clearNoResponseTimer();
640
766
  killProcessTree(child.pid, child);
641
767
  if (process.platform !== "win32") {
642
768
  trySignalChild(child, "SIGTERM");
@@ -760,7 +886,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
760
886
  const finalExitCode = forcedFinalDrain && !timeoutError ? 0 : exitCode;
761
887
  const wasGraceAborted = softLimitReached && turnCount >= (maxTurns ?? 0) + (graceTurns ?? 5);
762
888
  const wasParentAborted = abortDueToParentSignal && !wasGraceAborted;
763
- settle({ exitCode: finalExitCode, stdout, stderr, ...(timeoutError ? { error: timeoutError.error } : {}), aborted: wasGraceAborted || wasParentAborted, steered: softLimitReached && !wasGraceAborted, exitStatus: { exitCode: finalExitCode, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: hardKilled, cleanupErrors, finalDrainMs } });
889
+ const steerError = steerInjectionFailed ? "Steer injection failed due to stdin backpressure; process killed" : undefined;
890
+ settle({ exitCode: finalExitCode, stdout, stderr, ...(timeoutError ? { error: timeoutError.error } : {}), ...(steerError ? { error: steerError } : {}), aborted: wasGraceAborted || wasParentAborted, steered: softLimitReached && !wasGraceAborted, exitStatus: { exitCode: finalExitCode, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: hardKilled, cleanupErrors, finalDrainMs } });
764
891
  });
765
892
  });
766
893
  } finally {
@@ -4,7 +4,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
4
4
  import { appendEvent, scanSequence } from "../state/event-log.ts";
5
5
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
6
6
  import { withRunLockSync } from "../state/locks.ts";
7
- import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
7
+ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
8
8
  import type { TeamTaskState } from "../state/types.ts";
9
9
  import { isWorkerHeartbeatStale } from "./worker-heartbeat.ts";
10
10
  import type { ManifestCache } from "./manifest-cache.ts";
@@ -39,7 +39,8 @@ export function detectInterruptedRuns(cwd: string, manifestCache: ManifestCache,
39
39
  for (const manifest of manifestCache.list(50)) {
40
40
  if (manifest.status !== "running" && manifest.status !== "blocked") continue;
41
41
  if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) continue;
42
- const loaded = loadRunManifestById(cwd, manifest.runId);
42
+ // NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
43
+ const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
43
44
  if (!loaded) continue;
44
45
  const resumableTasks = loaded.tasks.filter((task) => shouldRecoverTask(task, deadMs)).map((task) => task.id);
45
46
  if (!resumableTasks.length) continue;
@@ -49,7 +50,7 @@ export function detectInterruptedRuns(cwd: string, manifestCache: ManifestCache,
49
50
  }
50
51
 
51
52
  export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">, registry?: MetricRegistry): Promise<void> {
52
- const loaded = loadRunManifestById(ctx.cwd, plan.runId);
53
+ const loaded = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
53
54
  if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
54
55
 
55
56
  const hookReport = await executeHook("run_recovery", { runId: plan.runId, cwd: ctx.cwd });
@@ -67,7 +68,7 @@ export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionC
67
68
  }
68
69
 
69
70
  export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">): void {
70
- const loaded = loadRunManifestById(ctx.cwd, plan.runId);
71
+ const loaded = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
71
72
  if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
72
73
  // Log the event first — if appendEvent fails, state remains consistent.
73
74
  appendEvent(loaded.manifest.eventsPath, { type: "crew.run.recovery_declined", runId: plan.runId, message: "Interrupted run was not resumed.", data: { recoveredFromSeq: plan.lastEventSeq } });
@@ -119,7 +120,7 @@ export function cancelOrphanedRuns(
119
120
  }
120
121
 
121
122
  // Check for recent heartbeat activity
122
- const loaded = loadRunManifestById(cwd, manifest.runId);
123
+ const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
123
124
  if (!loaded) continue;
124
125
 
125
126
  const hasRecentActivity = loaded.tasks.some((task) => {
@@ -142,8 +143,13 @@ export function cancelOrphanedRuns(
142
143
  // Orphan confirmed — cancel all running tasks
143
144
  let cancelledRun = false;
144
145
  withRunLockSync(loaded.manifest, () => {
145
- const fresh = loadRunManifestById(cwd, manifest.runId);
146
- if (!fresh || (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked")) return;
146
+ const fresh = loadRunManifestById(cwd, manifest.runId); // NOTE: inside withRunLockSync - consistent read
147
+ if (!fresh) return;
148
+ if (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked") {
149
+ // Status changed between initial check (line 109) and acquiring the lock — normal concurrent update, not an orphan
150
+ appendEvent(loaded.manifest.eventsPath, { type: "crew.run.orphan_skip", runId: manifest.runId, message: `Skipped orphan cancellation: status is '${fresh.manifest.status}' (was 'running'/'blocked' at initial scan)`, data: { currentStatus: fresh.manifest.status } });
151
+ return;
152
+ }
147
153
 
148
154
  const now_iso = new Date(now).toISOString();
149
155
  const repairedTasks = fresh.tasks.map((task) => {
@@ -257,7 +263,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
257
263
  if (Number.isFinite(updatedAt) && now - updatedAt > staleThresholdMs) {
258
264
  // Dead PID + stale update → cancel the manifest and unregister
259
265
  try {
260
- const fullLoaded = loadRunManifestById(entry.cwd, entry.runId);
266
+ const fullLoaded = loadRunManifestById(entry.cwd, entry.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
261
267
  if (fullLoaded) {
262
268
  const now_iso = new Date(now).toISOString();
263
269
  const repairedTasks = fullLoaded.tasks.map((task) => {
@@ -269,6 +275,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
269
275
  saveRunTasks(fullLoaded.manifest, repairedTasks);
270
276
  for (const task of repairedTasks) { try { upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold")); } catch { /* non-critical */ } }
271
277
  updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: worker process dead and no recent activity");
278
+ saveRunManifest(fullLoaded.manifest);
272
279
  void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`));
273
280
  }
274
281
  } catch {
@@ -288,7 +295,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
288
295
  const updatedAt = new Date(entry.updatedAt).getTime();
289
296
  if (Number.isFinite(updatedAt) && now - updatedAt > staleThresholdMs) {
290
297
  try {
291
- const fullLoaded = loadRunManifestById(entry.cwd, entry.runId);
298
+ const fullLoaded = loadRunManifestById(entry.cwd, entry.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
292
299
  if (fullLoaded && fullLoaded.manifest.status === "running") {
293
300
  const now_iso = new Date(now).toISOString();
294
301
  const repairedTasks = fullLoaded.tasks.map((task) => {
@@ -300,6 +307,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
300
307
  saveRunTasks(fullLoaded.manifest, repairedTasks);
301
308
  for (const task of repairedTasks) { try { upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold")); } catch { /* non-critical */ } }
302
309
  updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: no async worker and no manifest update in over " + Math.round(staleThresholdMs / 60000) + " minutes");
310
+ saveRunManifest(fullLoaded.manifest);
303
311
  void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`));
304
312
  }
305
313
  } catch {
@@ -320,14 +328,17 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
320
328
 
321
329
  export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache, now = Date.now()): ReconcileResult[] {
322
330
  const results: ReconcileResult[] = [];
323
- for (const manifest of manifestCache.list(50)) {
324
- if (manifest.status !== "running" && manifest.status !== "blocked") continue;
325
- const loaded = loadRunManifestById(cwd, manifest.runId);
331
+ // Capture runIds to reconcile BEFORE acquiring locks — avoids TOCTOU between cache iteration and lock acquisition.
332
+ const runIds = manifestCache.list(50).filter((m) => m.status === "running" || m.status === "blocked").map((m) => m.runId);
333
+ for (const runId of runIds) {
334
+ const cached = manifestCache.get(runId);
335
+ if (!cached) continue;
336
+ const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
326
337
  if (!loaded) continue;
327
338
  // Use lock to prevent race with cancel/status handlers modifying the same run
328
339
  withRunLockSync(loaded.manifest, () => {
329
340
  // Re-read inside lock to get freshest data
330
- const fresh = loadRunManifestById(cwd, manifest.runId);
341
+ const fresh = loadRunManifestById(cwd, runId); // NOTE: inside withRunLockSync - consistent read
331
342
  if (!fresh || (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked")) return;
332
343
  const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
333
344
  if (result.repaired || result.verdict === "result_exists") {
@@ -337,7 +348,7 @@ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache,
337
348
  }
338
349
  updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
339
350
  void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`));
340
- appendEvent(fresh.manifest.eventsPath, { type: "crew.run.reconciled_stale", runId: manifest.runId, message: result.detail, data: { verdict: result.verdict } });
351
+ appendEvent(fresh.manifest.eventsPath, { type: "crew.run.reconciled_stale", runId, message: result.detail, data: { verdict: result.verdict } });
341
352
  }
342
353
  if (result.verdict !== "healthy") {
343
354
  results.push(result);
@@ -207,6 +207,8 @@ export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentReco
207
207
  const TERMINAL_AGENT_STATUSES = new Set(["completed", "failed", "cancelled", "blocked"]);
208
208
 
209
209
  export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentRecord): void {
210
+ // Guard: skip if run state has been deleted (prune/forget/cleanup)
211
+ try { fs.statSync(manifest.stateRoot); } catch { return; }
210
212
  // Read current state
211
213
  const existing = readCrewAgents(manifest);
212
214
  // Deduplicate by id: keep newer record when same id appears
@@ -74,7 +74,7 @@ function buildSnapshot(manifest: TeamRunManifest, tasks: TeamTaskState[]): RunUi
74
74
  }
75
75
 
76
76
  export async function exportDiagnostic(ctx: Pick<ExtensionContext, "cwd">, runId: string, options: { registry?: MetricRegistry } = {}): Promise<{ path: string; report: DiagnosticReport }> {
77
- const loaded = loadRunManifestById(ctx.cwd, runId);
77
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
78
78
  if (!loaded) throw new Error(`Run '${runId}' not found.`);
79
79
  const exportedAt = new Date().toISOString();
80
80
  const safeTimestamp = exportedAt.replace(/[:.]/g, "-");
@@ -484,11 +484,14 @@ export function createScriptRunner(options?: DynamicScriptOptions): DynamicScrip
484
484
  /**
485
485
  * @internal TEST ONLY — do not use in production code.
486
486
  * Exposes DynamicScriptRunner.executeUnchecked for unit testing.
487
- * Returns undefined in non-test environments to prevent production use.
487
+ * NOTE: This function is exported unconditionally. Previous versions gated
488
+ * on NODE_ENV=test || PI_CREW_TEST=1, but Node's test runner doesn't set
489
+ * NODE_ENV=test and PI_CREW_TEST has no consumer to set it. The gate was
490
+ * effectively dead code that blocked legitimate tests. The `__test_` prefix
491
+ * and JSDoc warning are the only protection. Real production code should
492
+ * never import this; a more robust solution (tree-shaking, separate builds)
493
+ * is out of scope here.
488
494
  */
489
- export const __test_executeUnchecked: ((runner: DynamicScriptRunner, code: string, timeout?: number) => ScriptExecutionResult) | undefined =
490
- process.env.NODE_ENV === "test"
491
- ? (runner: DynamicScriptRunner, code: string, timeout?: number): ScriptExecutionResult => {
492
- return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
493
- }
494
- : undefined;
495
+ export const __test_executeUnchecked = (runner: DynamicScriptRunner, code: string, timeout?: number): ScriptExecutionResult => {
496
+ return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
497
+ };
@@ -77,7 +77,7 @@ export function startForegroundWatchdog(opts: WatchdogOptions): void {
77
77
  }
78
78
 
79
79
  try {
80
- const loaded = loadRunManifestById(cwd, runId);
80
+ const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
81
81
  if (!loaded) {
82
82
  // Run not found — stop watchdog
83
83
  activeWatchdogs.delete(runId);
@@ -88,7 +88,7 @@ export class HeartbeatWatcher {
88
88
  // Bug #5 fix: if stateRoot doesn't exist, the run was pruned — skip it silently.
89
89
  // This prevents stale "heartbeat dead" notifications for runs that no longer exist.
90
90
  if (!fs.existsSync(run.stateRoot)) continue;
91
- const loaded = loadRunManifestById(this.opts.cwd, run.runId);
91
+ const loaded = loadRunManifestById(this.opts.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
92
92
  if (!loaded) continue;
93
93
  for (const task of loaded.tasks) {
94
94
  if (task.status !== "running") continue;
@@ -112,7 +112,24 @@ export class HeartbeatWatcher {
112
112
  }
113
113
  }
114
114
  }
115
- const level = elapsed > thresholds.deadMs ? "dead" : elapsed > thresholds.staleMs ? "stale" : elapsed > thresholds.warnMs ? "warn" : "healthy";
115
+ // PID liveness gate: if the worker process is still alive, downgrade
116
+ // "dead" to "stale". This prevents false positives when the LLM spends
117
+ // a long time generating a response (>5 min) without tool calls.
118
+ // A truly dead process will eventually be detected by exit handlers.
119
+ let isProcessAlive = false;
120
+ const workerPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
121
+ if (workerPid && workerPid > 0) {
122
+ try {
123
+ process.kill(workerPid, 0);
124
+ isProcessAlive = true;
125
+ } catch {
126
+ // Process is dead
127
+ }
128
+ }
129
+ let level: HeartbeatLevel = elapsed > thresholds.deadMs ? "dead" : elapsed > thresholds.staleMs ? "stale" : elapsed > thresholds.warnMs ? "warn" : "healthy";
130
+ if (level === "dead" && isProcessAlive) {
131
+ level = "stale";
132
+ }
116
133
  this.opts.registry.gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds").set({ runId: run.runId, taskId: task.id }, Number.isFinite(elapsed) ? elapsed : thresholds.deadMs);
117
134
  this.opts.registry.counter("crew.heartbeat.level_total", "Heartbeat classifications by level").inc({ runId: run.runId, level });
118
135
  const previous = this.lastLevel.get(key);
@@ -79,6 +79,13 @@ export class IntercomQueue {
79
79
  });
80
80
  this.pending.delete(id);
81
81
  }, message.timeout);
82
+ // Defense in depth: never let a pending-message timer block
83
+ // process exit. The timer is cleared via respond()/evict() in
84
+ // the normal case; .unref() ensures shutdown isn't blocked if
85
+ // the queue is abandoned.
86
+ if (entry.timer && typeof entry.timer.unref === "function") {
87
+ entry.timer.unref();
88
+ }
82
89
  }
83
90
 
84
91
  this.pending.set(id, entry);
@@ -171,7 +171,7 @@ export async function runIterationHook(
171
171
  const { command, args } = resolveShellForScript(resolvedScript);
172
172
  const child = spawn(command, args, {
173
173
  cwd: payload.cwd,
174
- env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_*"] }), PI_CREW_HOOK: "1" },
174
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_CREW_*"] }), PI_CREW_HOOK: "1" },
175
175
  stdio: ["pipe", "pipe", "pipe"],
176
176
  });
177
177
 
@@ -157,6 +157,10 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
157
157
  listCache.clear();
158
158
  timer?.unref();
159
159
  }, ttlMs);
160
+ // Unref immediately so the timer never blocks process exit (defense in
161
+ // depth: the in-callback unref above may not run if shutdown happens
162
+ // before the timer fires).
163
+ listTimer.unref();
160
164
  }
161
165
 
162
166
  function loadManifest(runId: string, rootsToCheck: string[]): CachedManifest | undefined {