pi-crew 0.9.48 → 0.9.50

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 (86) hide show
  1. package/AGENTS.md +18 -0
  2. package/CHANGELOG.md +314 -0
  3. package/dist/build-meta.json +70 -42
  4. package/dist/index.mjs +505 -430
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/decisions/2026-07-24-oidc-trusted-publishing.md +112 -0
  7. package/package.json +2 -3
  8. package/skills/.gitkeep +0 -0
  9. package/skills/distill-persona/BUILD-NOTES.md +55 -0
  10. package/skills/distill-persona/SKILL.md +550 -0
  11. package/skills/distill-persona/UPGRADE-LOG-RESEARCH-SKILLS.md +100 -0
  12. package/skills/distill-persona/references/coverage-manifest.md +65 -0
  13. package/skills/distill-persona/references/cross-skill-differentiation.md +12 -0
  14. package/skills/distill-persona/references/description-discipline.md +6 -0
  15. package/skills/distill-persona/references/diagnostic-path.md +25 -0
  16. package/skills/distill-persona/references/distillation-field-synthesis-pass2.md +59 -0
  17. package/skills/distill-persona/references/distillation-field-synthesis.md +108 -0
  18. package/skills/distill-persona/references/fidelity-rubric.md +19 -0
  19. package/skills/distill-persona/references/field-models.md +20 -0
  20. package/skills/distill-persona/references/handoff.md +42 -0
  21. package/skills/distill-persona/references/optional-body-sections.md +9 -0
  22. package/skills/distill-persona/references/registry-routing.md +11 -0
  23. package/skills/distill-persona/references/research/lesson-memory-shortcut.md +33 -0
  24. package/skills/distill-persona/references/research/r1-a-examples.md +23 -0
  25. package/skills/distill-persona/references/research/r1-b-scripts.md +26 -0
  26. package/skills/distill-persona/references/research/r1-c-human-readme.md +31 -0
  27. package/skills/distill-persona/references/research/r1-d-tests.md +28 -0
  28. package/skills/distill-persona/references/research/r1-verification.md +36 -0
  29. package/skills/distill-persona/references/research/r2-low-yield.md +26 -0
  30. package/skills/distill-persona/references/self-upgrade-directive.md +20 -0
  31. package/skills/distill-persona/references/taste-principles.md +8 -0
  32. package/skills/distill-persona/references/topic-variant.md +13 -0
  33. package/skills/distill-persona/references/update-mode.md +7 -0
  34. package/skills/distill-persona/scripts/fidelity_eval.py +244 -0
  35. package/skills/distill-persona/scripts/validate-run.mjs +297 -0
  36. package/skills/distill-persona/scripts/validate-skill-structure.mjs +177 -0
  37. package/skills/distill-software/BUILD-NOTES.md +56 -0
  38. package/skills/distill-software/SKILL.md +363 -0
  39. package/skills/distill-software/references/handoff.md +47 -0
  40. package/skills/distill-software/scripts/code_dna.py +290 -0
  41. package/skills/research/DISTILLATION-PROCESS-CHECKLIST.md +120 -0
  42. package/skills/research/EXCAVATION-CHECKLIST.md +142 -0
  43. package/skills/research/FIDELITY.md +180 -0
  44. package/skills/research/SKILL.md +432 -0
  45. package/skills/research/references/anti-patterns.md +184 -0
  46. package/skills/research/references/fidelity.md +241 -0
  47. package/skills/research/references/handoff.md +48 -0
  48. package/skills/research/references/research-protocol.md +162 -0
  49. package/skills/research/references/source-inventory.md +135 -0
  50. package/skills/research/references/verified-models.md +163 -0
  51. package/skills/research/scripts/__pycache__/safe_io.cpython-312.pyc +0 -0
  52. package/skills/research/scripts/code_dna.py +233 -0
  53. package/skills/research/scripts/emit_run_summary.py +142 -0
  54. package/skills/research/scripts/safe_io.py +314 -0
  55. package/skills/research/scripts/source_evaluator.py +234 -0
  56. package/skills/research/scripts/validate-skill-structure.mjs +177 -0
  57. package/skills/research/scripts/verify_citations.py +225 -0
  58. package/skills/security-priority.json +28 -0
  59. package/src/config/config.ts +1 -0
  60. package/src/config/role-tools.ts +6 -3
  61. package/src/config/types.ts +8 -0
  62. package/src/extension/crew-cleanup.ts +18 -1
  63. package/src/extension/crew-vibes/index.ts +11 -2
  64. package/src/extension/register.ts +1 -1
  65. package/src/extension/registration/command-registration.ts +1 -0
  66. package/src/extension/registration/commands.ts +7 -3
  67. package/src/extension/registration/lifecycle-handlers.ts +1 -3
  68. package/src/extension/registration/ui.ts +4 -0
  69. package/src/extension/registration/viewers.ts +3 -0
  70. package/src/extension/team-tool/run.ts +7 -6
  71. package/src/runtime/background-runner.ts +11 -16
  72. package/src/runtime/chain-runner.ts +3 -2
  73. package/src/runtime/heartbeat-watcher.ts +28 -1
  74. package/src/runtime/pipeline-runner.ts +8 -7
  75. package/src/runtime/task-runner.ts +165 -119
  76. package/src/schema/config-schema.ts +1 -0
  77. package/src/ui/live-run-sidebar.ts +2 -0
  78. package/src/ui/mascot.ts +11 -9
  79. package/src/ui/render-coalescer.ts +9 -0
  80. package/src/ui/run-snapshot-cache.ts +10 -11
  81. package/src/ui/terminal-status.ts +5 -0
  82. package/src/ui/widget/index.ts +3 -5
  83. package/src/ui/widget/widget-types.ts +0 -1
  84. package/src/utils/gh-protocol.ts +9 -8
  85. package/workflows/distill.workflow.md +198 -0
  86. package/assets/runner-spritesheet.png +0 -0
@@ -15,6 +15,7 @@ import { assertCleanLeaderAsync, findGitRootAsync } from "../../worktree/worktre
15
15
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only import for TS inference
16
16
  const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamRunFn;
17
17
 
18
+ import { errorMessage } from "../../utils/guards.ts";
18
19
  import { logInternalError } from "../../utils/internal-error.ts";
19
20
  import { resolveContainedPath, resolveRealContainedPath } from "../../utils/safe-paths.ts";
20
21
 
@@ -432,7 +433,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
432
433
  try {
433
434
  await assertCleanLeaderAsync(gitRoot);
434
435
  } catch (err) {
435
- const msg = err instanceof Error ? err.message : String(err);
436
+ const msg = errorMessage(err);
436
437
  return result(
437
438
  `${msg}\nCommit or stash changes before using worktree mode, or use workspaceMode: 'single'.`,
438
439
  { action: "run", status: "error" },
@@ -655,7 +656,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
655
656
  // Round-11 runtime fix: persist manifest with status=failed when runner throws
656
657
  // (e.g., script timeout, script syntax error, async failure). Previously the
657
658
  // manifest stayed at 'queued' indefinitely, leaving an orphan state file.
658
- const failureReason = runnerError instanceof Error ? runnerError.message : String(runnerError);
659
+ const failureReason = errorMessage(runnerError);
659
660
  const failedManifest = {
660
661
  ...dwfManifest,
661
662
  status: "failed" as const,
@@ -856,13 +857,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
856
857
  workspaceId: ctx.sessionId ?? ctx.cwd,
857
858
  });
858
859
  } catch (waitError: unknown) {
859
- const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
860
+ const waitErrMsg = errorMessage(waitError);
860
861
  return result(
861
862
  [
862
863
  `pi-crew run timed out or failed: ${updatedManifest.runId}`,
863
864
  `Team: ${team.name}`,
864
865
  `Workflow: ${workflow.name}`,
865
- `Error: ${errorMessage}`,
866
+ `Error: ${waitErrMsg}`,
866
867
  "",
867
868
  `Check status with: team status runId=${updatedManifest.runId}`,
868
869
  `State: ${updatedManifest.stateRoot}`,
@@ -970,13 +971,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
970
971
  workspaceId: ctx.sessionId ?? ctx.cwd,
971
972
  });
972
973
  } catch (waitError: unknown) {
973
- const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
974
+ const waitErrMsg = errorMessage(waitError);
974
975
  return result(
975
976
  [
976
977
  `pi-crew run timed out or failed: ${updatedManifest.runId}`,
977
978
  `Team: ${team.name}`,
978
979
  `Workflow: ${workflow.name}`,
979
- `Error: ${errorMessage}`,
980
+ `Error: ${waitErrMsg}`,
980
981
  "",
981
982
  `Check status with: team status runId=${updatedManifest.runId}`,
982
983
  `State: ${updatedManifest.stateRoot}`,
@@ -8,6 +8,7 @@ import { withRunLockSync } from "../state/locks.ts";
8
8
  import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunStatus } from "../state/state-store.ts";
9
9
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
10
10
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
11
+ import { errorMessage } from "../utils/guards.ts";
11
12
  import { projectCrewRoot } from "../utils/paths.ts";
12
13
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
13
14
  // Heavy runtime — lazy-loaded to avoid pulling team-runner into background-runner
@@ -175,7 +176,7 @@ function setupUnhandledRejectionGuard(
175
176
  setExitFlag: () => void,
176
177
  ): void {
177
178
  process.on("unhandledRejection", (reason, promise) => {
178
- const message = reason instanceof Error ? reason.message : String(reason);
179
+ const message = errorMessage(reason);
179
180
  console.error("[background-runner] UNHANDLED REJECTION:", reason);
180
181
  console.error("[background-runner] Stack:", reason instanceof Error ? reason.stack : "N/A");
181
182
  try {
@@ -234,9 +235,7 @@ function runCleanup(
234
235
  try {
235
236
  killed = terminateActiveChildPiProcesses();
236
237
  } catch (error) {
237
- console.log(
238
- `[background-runner] runCleanup: terminateActiveChildPiProcesses error: ${error instanceof Error ? error.message : String(error)}`,
239
- );
238
+ console.log(`[background-runner] runCleanup: terminateActiveChildPiProcesses error: ${errorMessage(error)}`);
240
239
  }
241
240
  console.log(`[background-runner] runCleanup: killed ${killed} child processes`);
242
241
  // FIX Issue #5: Unregister this worker from the orphan registry on exit.
@@ -245,13 +244,13 @@ function runCleanup(
245
244
  try {
246
245
  unregisterWorker(process.pid);
247
246
  } catch (error) {
248
- console.log(`[background-runner] runCleanup: unregisterWorker error: ${error instanceof Error ? error.message : String(error)}`);
247
+ console.log(`[background-runner] runCleanup: unregisterWorker error: ${errorMessage(error)}`);
249
248
  if (eventsPath) {
250
249
  try {
251
250
  appendEvent(eventsPath, {
252
251
  type: "background.unregister_worker_failed",
253
252
  runId: argValue("--run-id") ?? "unknown",
254
- message: `unregisterWorker failed: ${error instanceof Error ? error.message : String(error)}`,
253
+ message: `unregisterWorker failed: ${errorMessage(error)}`,
255
254
  data: { pid: process.pid },
256
255
  });
257
256
  } catch {
@@ -391,7 +390,7 @@ async function main(): Promise<void> {
391
390
  staleMs: 30_000,
392
391
  });
393
392
  } catch (lockErr) {
394
- throw new Error(`Failed to acquire lock for run '${runId}': ${lockErr instanceof Error ? lockErr.message : String(lockErr)}`);
393
+ throw new Error(`Failed to acquire lock for run '${runId}': ${errorMessage(lockErr)}`);
395
394
  }
396
395
  if (!loaded) throw new Error(`Run '${runId}' not found.`);
397
396
  let { manifest, tasks } = loaded;
@@ -750,9 +749,7 @@ async function main(): Promise<void> {
750
749
  }
751
750
  console.log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`);
752
751
  } catch (execError) {
753
- console.log(
754
- `[background-runner] executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
755
- );
752
+ console.log(`[background-runner] executeTeamRun THREW: ${errorMessage(execError)}`);
756
753
  console.log(`[background-runner] stack: ${execError instanceof Error ? execError.stack : "N/A"}`);
757
754
  throw execError;
758
755
  }
@@ -782,7 +779,7 @@ async function main(): Promise<void> {
782
779
  } catch {
783
780
  /* best-effort */
784
781
  }
785
- const message = error instanceof Error ? error.message : String(error);
782
+ const message = errorMessage(error);
786
783
  manifest = updateRunStatus(manifest, "failed", message);
787
784
  appendEvent(manifest.eventsPath, {
788
785
  type: "async.failed",
@@ -790,7 +787,7 @@ async function main(): Promise<void> {
790
787
  message,
791
788
  });
792
789
  process.exitCode = 1;
793
- console.log(`[background-runner] catch block, error=${error instanceof Error ? error.message : String(error)}`);
790
+ console.log(`[background-runner] catch block, error=${errorMessage(error)}`);
794
791
  } finally {
795
792
  // FIX Issue #4: Use shared runCleanup() function for consistent cleanup
796
793
  // across all exit paths (normal, unhandled rejection, main() exception).
@@ -807,9 +804,7 @@ async function main(): Promise<void> {
807
804
  manifest.eventsPath,
808
805
  );
809
806
  } catch (cleanupError) {
810
- console.error(
811
- `[background-runner] runCleanup threw: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
812
- );
807
+ console.error(`[background-runner] runCleanup threw: ${errorMessage(cleanupError)}`);
813
808
  }
814
809
  // NOTE: If exitDueToRejection was set, runCleanup() already called process.exit(1)
815
810
  // so this finally block never continues past that point.
@@ -828,7 +823,7 @@ async function main(): Promise<void> {
828
823
  try {
829
824
  await main();
830
825
  } catch (err) {
831
- console.error(`[background-runner] DEBUG: main() uncaught: ${err instanceof Error ? err.message : String(err)}`);
826
+ console.error(`[background-runner] DEBUG: main() uncaught: ${errorMessage(err)}`);
832
827
  // FIX Issue #1: Set the flag so the finally block's runCleanup() call
833
828
  // will trigger process.exit(1) after cleanup completes. Previously this
834
829
  // called process.exit(1) directly, bypassing the finally block and leaving
@@ -10,6 +10,7 @@
10
10
  * @see docs/pi-boomerang-integration-plan.md
11
11
  */
12
12
 
13
+ import { errorMessage } from "../utils/guards.ts";
13
14
  import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
14
15
  import { parseChainDSL } from "./chain-parser.ts";
15
16
  import type { HandoffManager, HandoffSummary, TaskPacket, TaskResult } from "./handoff-manager.ts";
@@ -250,14 +251,14 @@ export class ChainRunner {
250
251
  });
251
252
  }
252
253
  } catch (error) {
253
- const errorMessage = error instanceof Error ? error.message : String(error);
254
+ const errMsg = errorMessage(error);
254
255
 
255
256
  stepResults.push({
256
257
  step: i + 1,
257
258
  name: step.name,
258
259
  outcome: "failure",
259
260
  duration: Date.now() - stepStart,
260
- error: errorMessage,
261
+ error: errMsg,
261
262
  });
262
263
 
263
264
  // Stop chain on failure unless configured to continue
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { NotificationDescriptor } from "../extension/notification-router.ts";
3
4
  import type { MetricRegistry } from "../observability/metric-registry.ts";
4
5
  import { appendEvent } from "../state/event-log.ts";
@@ -142,6 +143,28 @@ export class HeartbeatWatcher {
142
143
  if (level === "dead" && isProcessAlive) {
143
144
  level = "stale";
144
145
  }
146
+ // W8 fix: completion-artifact check — prevents false-positive "dead"
147
+ // during the exit-before-manifest-update race. When a worker process
148
+ // exits normally after completing its task, the result artifact is
149
+ // already on disk, but the manifest status update may lag by a few
150
+ // seconds (status + finishedAt are set atomically in task-runner.ts).
151
+ // If the result file exists, the task completed — downgrade to
152
+ // "stale" so the watcher doesn't fire a misleading "dead" notification
153
+ // for a task that already produced its output.
154
+ // W8-fix-v2 — path-traversal defense-in-depth. task.id is
155
+ // generated internally (e.g. "ts1", "ts2") but we still
156
+ // resolve the candidate path and verify it's strictly
157
+ // contained within <artifactsRoot>/results/. If task.id
158
+ // contained "../" or absolute path segments, the containment
159
+ // check fails and we skip the W8 check (fail-closed: don't
160
+ // accidentally treat a malicious task ID as "completed").
161
+ if (level === "dead" && !isProcessAlive && loaded.manifest.artifactsRoot) {
162
+ const resultsDir = path.resolve(loaded.manifest.artifactsRoot, "results");
163
+ const candidate = path.resolve(resultsDir, `${task.id}.txt`);
164
+ if (candidate.startsWith(resultsDir + path.sep) && fs.existsSync(candidate)) {
165
+ level = "stale";
166
+ }
167
+ }
145
168
  this.opts.registry
146
169
  .gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds")
147
170
  .set({ runId: run.runId, taskId: task.id }, Number.isFinite(elapsed) ? elapsed : thresholds.deadMs);
@@ -161,12 +184,16 @@ export class HeartbeatWatcher {
161
184
  elapsedMs: Number.isFinite(elapsed) ? elapsed : undefined,
162
185
  },
163
186
  });
187
+ // W9 fix — prefix title with short run label (first 8 chars of runId)
188
+ // so ambient notifications are scannable when multiple runs are
189
+ // in flight. Full runId remains in the notification object.
190
+ const runLabel = run.runId.slice(0, 8);
164
191
  this.opts.router.enqueue({
165
192
  id: `dead_${run.runId}_${task.id}`,
166
193
  severity: "warning",
167
194
  source: "heartbeat-watcher",
168
195
  runId: run.runId,
169
- title: `Task ${task.id} heartbeat dead`,
196
+ title: `[${runLabel}] Task ${task.id} heartbeat dead`,
170
197
  body: "Background watcher detected a stuck worker.",
171
198
  });
172
199
  this.opts.onDead?.(run.runId, task.id, Number.isFinite(elapsed) ? elapsed : thresholds.deadMs);
@@ -1,5 +1,6 @@
1
1
  import { errors } from "../errors.ts";
2
2
  import { appendEventAsync, flushEventLogBuffer } from "../state/event-log.ts";
3
+ import { errorMessage } from "../utils/guards.ts";
3
4
  import type { WorkflowConfig } from "../workflows/workflow-config.ts";
4
5
  import { mapConcurrent } from "./parallel-utils.ts";
5
6
 
@@ -166,26 +167,26 @@ export class PipelineRunner {
166
167
  });
167
168
  } catch (error) {
168
169
  const duration = Date.now() - stageStartTime;
169
- const errorMessage = error instanceof Error ? error.message : String(error);
170
+ const errMsg = errorMessage(error);
170
171
 
171
172
  if (effectiveStopOnError) {
172
173
  stages.push({
173
174
  name: stage.name,
174
175
  status: "failed",
175
176
  results: [],
176
- error: errorMessage,
177
+ error: errMsg,
177
178
  duration,
178
179
  });
179
180
 
180
181
  await appendEventAsync(eventsPath, {
181
182
  type: "pipeline:stage_failed",
182
183
  runId,
183
- message: `Stage '${stage.name}' failed: ${errorMessage}`,
184
+ message: `Stage '${stage.name}' failed: ${errMsg}`,
184
185
  data: {
185
186
  stageIndex: i,
186
187
  stageName: stage.name,
187
188
  duration,
188
- error: errorMessage,
189
+ error: errMsg,
189
190
  },
190
191
  });
191
192
 
@@ -193,7 +194,7 @@ export class PipelineRunner {
193
194
  type: "pipeline:failed",
194
195
  runId,
195
196
  message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
196
- data: { failedStage: stage.name, error: errorMessage },
197
+ data: { failedStage: stage.name, error: errMsg },
197
198
  });
198
199
 
199
200
  return {
@@ -207,7 +208,7 @@ export class PipelineRunner {
207
208
  name: stage.name,
208
209
  status: "failed",
209
210
  results: [],
210
- error: errorMessage,
211
+ error: errMsg,
211
212
  duration,
212
213
  });
213
214
 
@@ -219,7 +220,7 @@ export class PipelineRunner {
219
220
  stageIndex: i,
220
221
  stageName: stage.name,
221
222
  duration,
222
- error: errorMessage,
223
+ error: errMsg,
223
224
  },
224
225
  });
225
226
  }
@@ -485,131 +485,177 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
485
485
  data: { role: task.role, model: model ?? "default" },
486
486
  });
487
487
  upsertCrewAgent(manifest, recordFromTask(manifest, task, "child-process"));
488
- const childResult = await runChildPi({
489
- cwd: task.cwd,
490
- task: prompt,
491
- agent: input.agent,
492
- model,
493
- signal: input.signal,
494
- transcriptPath,
495
- maxDepth: input.limits?.maxTaskDepth,
496
- skillPaths,
497
- maxTurns: input.runtimeConfig?.maxTurns,
498
- graceTurns: input.runtimeConfig?.graceTurns,
499
- inheritContext: input.runtimeConfig?.inheritContext,
500
- parentContext: input.parentContext,
501
- excludeContextBash: input.runtimeConfig?.excludeContextBash,
502
- sessionId: manifest.sessionId,
503
- role: task.role,
504
- runId: manifest.runId,
505
- agentId: task.id,
506
- artifactsRoot: manifest.artifactsRoot,
507
- steeringFile: resolveContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
508
- onSpawn: (pid) => {
509
- try {
510
- ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
511
- if (task.pendingSteers?.length) {
512
- const steeringDir = `${manifest.artifactsRoot}/steering`;
513
- // Fire-and-forget async write for steering events
514
- void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
515
- task.pendingSteers = [];
516
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
517
- }
518
- } catch (err) {
519
- logInternalError("task-runner.on-spawn", err as Error, `pid=${pid}, taskId=${task.id}`);
520
- }
521
- },
522
- onLifecycleEvent: (event: ChildPiLifecycleEvent) => {
523
- void appendEventAsync(manifest.eventsPath, {
524
- type: `worker.${event.type}` as const,
525
- runId: manifest.runId,
526
- taskId: task.id,
527
- message: `Worker lifecycle: ${event.type}${event.error ? ` error=${event.error}` : ""}${event.exitCode != null ? ` exit=${event.exitCode}` : ""}`,
528
- data: { ...event },
529
- }).catch((error) =>
530
- logInternalError("task-runner.lifecycle-event", error, `taskId=${task.id}, type=${event.type}`),
531
- );
532
- },
533
- onStdoutLine: (line) => {
534
- appendCrewAgentOutput(manifest, task.id, line);
535
- persistHeartbeat();
536
- // Check for supervisor contact requests from child Pi
537
- const contact = parseSupervisorContactFromLine(line);
538
- if (contact) {
539
- recordSupervisorContact(manifest, {
540
- runId: manifest.runId,
541
- ...contact,
542
- });
488
+ // W2 fix — wall-clock timeout per task. We create our own
489
+ // AbortController, link the caller's signal to it, and abort
490
+ // from a timer. The internal signal is passed to runChildPi so
491
+ // the existing SIGTERM → SIGKILL escalation in child-pi.ts
492
+ // handles cleanup. Prevents runaway agent loops (e.g. 11_build
493
+ // in the oh-my-pi distill run that re-verified completed files
494
+ // 14+ times).
495
+ const taskTimeoutMs = input.runtimeConfig?.taskTimeoutMs ?? 0;
496
+ const timeoutController = new AbortController();
497
+ // W2 fix (memory leak) — store the listener reference so we can
498
+ // removeEventListener() it in the finally block below. { once: true }
499
+ // alone is NOT enough: when the timeout fires first, the listener
500
+ // never fires → { once: true } never auto-removes → listener stays
501
+ // attached to input.signal for the rest of the run (run-level
502
+ // signal = long-lived → leak accumulates per task run).
503
+ let externalAbortListener: (() => void) | undefined;
504
+ if (input.signal) {
505
+ if (input.signal.aborted) {
506
+ timeoutController.abort(input.signal.reason);
507
+ } else {
508
+ externalAbortListener = () => timeoutController.abort(input.signal!.reason);
509
+ input.signal.addEventListener("abort", externalAbortListener, { once: true });
510
+ }
511
+ }
512
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
513
+ if (taskTimeoutMs > 0 && !timeoutController.signal.aborted) {
514
+ timeoutHandle = setTimeout(() => {
515
+ if (!timeoutController.signal.aborted) {
516
+ timeoutController.abort(new Error(`Task exceeded wall-clock timeout of ${taskTimeoutMs}ms`));
543
517
  }
544
- },
545
- onJsonEvent: (event) => {
546
- // Top-level error boundary: prevent any single event from crashing the task.
547
- // Errors are logged but processing continues so subsequent events still update state.
548
- try {
549
- appendCrewAgentEvent(manifest, task.id, event);
550
- if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event))
551
- collectedJsonEvents.push(event as Record<string, unknown>);
552
- if (collectedJsonEvents && collectedJsonEvents.length > 1000) {
553
- collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
554
- }
555
- // Accumulate lifetime usage via message_end events (survives compaction)
556
- if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
557
- const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
558
- if (msg?.role === "assistant") {
559
- const usage = msg.usage as Record<string, number> | undefined;
560
- if (usage) {
561
- task.lifetimeUsage = {
562
- input: (task.lifetimeUsage?.input ?? 0) + (usage.input ?? 0),
563
- output: (task.lifetimeUsage?.output ?? 0) + (usage.output ?? 0),
564
- cacheWrite: (task.lifetimeUsage?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0),
565
- };
566
- }
518
+ }, taskTimeoutMs);
519
+ timeoutHandle.unref?.();
520
+ }
521
+ let childResult;
522
+ try {
523
+ childResult = await runChildPi({
524
+ cwd: task.cwd,
525
+ task: prompt,
526
+ agent: input.agent,
527
+ model,
528
+ signal: timeoutController.signal,
529
+ transcriptPath,
530
+ maxDepth: input.limits?.maxTaskDepth,
531
+ skillPaths,
532
+ maxTurns: input.runtimeConfig?.maxTurns,
533
+ graceTurns: input.runtimeConfig?.graceTurns,
534
+ inheritContext: input.runtimeConfig?.inheritContext,
535
+ parentContext: input.parentContext,
536
+ excludeContextBash: input.runtimeConfig?.excludeContextBash,
537
+ sessionId: manifest.sessionId,
538
+ role: task.role,
539
+ runId: manifest.runId,
540
+ agentId: task.id,
541
+ artifactsRoot: manifest.artifactsRoot,
542
+ steeringFile: resolveContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
543
+ onSpawn: (pid) => {
544
+ try {
545
+ ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
546
+ if (task.pendingSteers?.length) {
547
+ const steeringDir = `${manifest.artifactsRoot}/steering`;
548
+ // Fire-and-forget async write for steering events
549
+ void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
550
+ task.pendingSteers = [];
551
+ tasks = persistSingleTaskUpdate(manifest, tasks, task);
567
552
  }
553
+ } catch (err) {
554
+ logInternalError("task-runner.on-spawn", err as Error, `pid=${pid}, taskId=${task.id}`);
568
555
  }
569
- persistHeartbeat();
570
- // Bug #3 fix: Write worker JSON events to background.log for debugging when running in background mode.
571
- // This supplements the event log so developers can see what the child Pi worker produced.
572
- if (process.env.PI_CREW_BACKGROUND_MODE === "1" && event) {
573
- const bgLogPath = `${manifest.stateRoot}/background.log`;
574
- const eventLine =
575
- typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
576
- // Fire-and-forget async write for background log
577
- void appendBackgroundLogAsync(bgLogPath, eventLine);
578
- }
579
- // Always keep in-memory agentProgress fresh (cheap) so the UI/events see
580
- // the latest progress, but THROTTLE the disk persist. Previously this
581
- // did a full locked read-parse-write of tasks.json on EVERY child JSON
582
- // event — a 200-event task produced 200 such cycles (Round 15 P1).
583
- // Final state is force-flushed on task completion (persistHeartbeat(true)).
584
- const nextProgress = applyAgentProgressEvent(
585
- task.agentProgress ?? emptyCrewAgentProgress(),
586
- event,
587
- task.startedAt,
556
+ },
557
+ onLifecycleEvent: (event: ChildPiLifecycleEvent) => {
558
+ void appendEventAsync(manifest.eventsPath, {
559
+ type: `worker.${event.type}` as const,
560
+ runId: manifest.runId,
561
+ taskId: task.id,
562
+ message: `Worker lifecycle: ${event.type}${event.error ? ` error=${event.error}` : ""}${event.exitCode != null ? ` exit=${event.exitCode}` : ""}`,
563
+ data: { ...event },
564
+ }).catch((error) =>
565
+ logInternalError("task-runner.lifecycle-event", error, `taskId=${task.id}, type=${event.type}`),
588
566
  );
589
- task = { ...task, agentProgress: nextProgress };
590
- tasks = updateTask(tasks, task);
591
- const progressNow = Date.now();
592
- if (progressNow - lastTaskProgressPersistedAt >= 500) {
593
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
594
- lastTaskProgressPersistedAt = progressNow;
595
- }
596
- // Bridge event to UI event bus for near-instant updates
597
- const bridgeEvent = bridgeEventFromJsonEvent(manifest.runId, task.id, event);
598
- if (bridgeEvent) streamBridge?.handler(bridgeEvent);
599
- // Feed overflow recovery tracker
600
- if (input.onJsonEvent) {
601
- input.onJsonEvent(task.id, manifest.runId, event);
567
+ },
568
+ onStdoutLine: (line) => {
569
+ appendCrewAgentOutput(manifest, task.id, line);
570
+ persistHeartbeat();
571
+ // Check for supervisor contact requests from child Pi
572
+ const contact = parseSupervisorContactFromLine(line);
573
+ if (contact) {
574
+ recordSupervisorContact(manifest, {
575
+ runId: manifest.runId,
576
+ ...contact,
577
+ });
602
578
  }
603
- if (!finalCheckpointWritten && isFinalChildEvent(event)) {
604
- finalCheckpointWritten = true;
605
- ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-stdout-final"));
579
+ },
580
+ onJsonEvent: (event) => {
581
+ // Top-level error boundary: prevent any single event from crashing the task.
582
+ // Errors are logged but processing continues so subsequent events still update state.
583
+ try {
584
+ appendCrewAgentEvent(manifest, task.id, event);
585
+ if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event))
586
+ collectedJsonEvents.push(event as Record<string, unknown>);
587
+ if (collectedJsonEvents && collectedJsonEvents.length > 1000) {
588
+ collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
589
+ }
590
+ // Accumulate lifetime usage via message_end events (survives compaction)
591
+ if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
592
+ const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
593
+ if (msg?.role === "assistant") {
594
+ const usage = msg.usage as Record<string, number> | undefined;
595
+ if (usage) {
596
+ task.lifetimeUsage = {
597
+ input: (task.lifetimeUsage?.input ?? 0) + (usage.input ?? 0),
598
+ output: (task.lifetimeUsage?.output ?? 0) + (usage.output ?? 0),
599
+ cacheWrite: (task.lifetimeUsage?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0),
600
+ };
601
+ }
602
+ }
603
+ }
604
+ persistHeartbeat();
605
+ // Bug #3 fix: Write worker JSON events to background.log for debugging when running in background mode.
606
+ // This supplements the event log so developers can see what the child Pi worker produced.
607
+ if (process.env.PI_CREW_BACKGROUND_MODE === "1" && event) {
608
+ const bgLogPath = `${manifest.stateRoot}/background.log`;
609
+ const eventLine =
610
+ typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
611
+ // Fire-and-forget async write for background log
612
+ void appendBackgroundLogAsync(bgLogPath, eventLine);
613
+ }
614
+ // Always keep in-memory agentProgress fresh (cheap) so the UI/events see
615
+ // the latest progress, but THROTTLE the disk persist. Previously this
616
+ // did a full locked read-parse-write of tasks.json on EVERY child JSON
617
+ // event — a 200-event task produced 200 such cycles (Round 15 P1).
618
+ // Final state is force-flushed on task completion (persistHeartbeat(true)).
619
+ const nextProgress = applyAgentProgressEvent(
620
+ task.agentProgress ?? emptyCrewAgentProgress(),
621
+ event,
622
+ task.startedAt,
623
+ );
624
+ task = { ...task, agentProgress: nextProgress };
625
+ tasks = updateTask(tasks, task);
626
+ const progressNow = Date.now();
627
+ if (progressNow - lastTaskProgressPersistedAt >= 500) {
628
+ tasks = persistSingleTaskUpdate(manifest, tasks, task);
629
+ lastTaskProgressPersistedAt = progressNow;
630
+ }
631
+ // Bridge event to UI event bus for near-instant updates
632
+ const bridgeEvent = bridgeEventFromJsonEvent(manifest.runId, task.id, event);
633
+ if (bridgeEvent) streamBridge?.handler(bridgeEvent);
634
+ // Feed overflow recovery tracker
635
+ if (input.onJsonEvent) {
636
+ input.onJsonEvent(task.id, manifest.runId, event);
637
+ }
638
+ if (!finalCheckpointWritten && isFinalChildEvent(event)) {
639
+ finalCheckpointWritten = true;
640
+ ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-stdout-final"));
641
+ }
642
+ persistChildProgress(event);
643
+ } catch (err) {
644
+ logInternalError("task-runner.on-json-event", err as Error, `taskId=${task.id}`);
606
645
  }
607
- persistChildProgress(event);
608
- } catch (err) {
609
- logInternalError("task-runner.on-json-event", err as Error, `taskId=${task.id}`);
610
- }
611
- },
612
- });
646
+ },
647
+ });
648
+ } finally {
649
+ if (timeoutHandle) clearTimeout(timeoutHandle);
650
+ // W2 fix — release the listener so it doesn't leak. {once:true}
651
+ // only auto-removes when the listener FIRES; if the timeout
652
+ // fires first, the listener never fires and stays attached
653
+ // to input.signal (run-level signal = long-lived → leak per
654
+ // task). Remove explicitly here.
655
+ if (externalAbortListener && input.signal) {
656
+ input.signal.removeEventListener("abort", externalAbortListener);
657
+ }
658
+ }
613
659
  const evidenceStatus = childResult.exitStatus?.cancelled
614
660
  ? "cancelled"
615
661
  : childResult.error || (childResult.exitCode && childResult.exitCode !== 0)
@@ -51,6 +51,7 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
51
51
  allowChildProcessFallback: Type.Optional(Type.Boolean()),
52
52
  maxTurns: Type.Optional(Type.Integer({ minimum: 1 })),
53
53
  graceTurns: Type.Optional(Type.Integer({ minimum: 1 })),
54
+ taskTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
54
55
  inheritContext: Type.Optional(Type.Boolean()),
55
56
  promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
56
57
  groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
@@ -270,10 +270,12 @@ export class LiveRunSidebar {
270
270
  if (isTerminal && !hasActiveAgents && !this.hasAutoClosed) {
271
271
  const autoCloseMs = this.config?.autoCloseDashboardMs ?? 3000;
272
272
  if (autoCloseMs > 0) {
273
+ if (this.autoCloseTimeout) clearTimeout(this.autoCloseTimeout);
273
274
  this.autoCloseTimeout = setTimeout(() => {
274
275
  this.hasAutoClosed = true;
275
276
  this.done(undefined);
276
277
  }, autoCloseMs);
278
+ this.autoCloseTimeout?.unref();
277
279
  lines.push(line(`auto-close in ${Math.round(autoCloseMs / 1000)}s…`, w));
278
280
  }
279
281
  }