pi-crew 0.11.1 → 0.11.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 (154) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +742 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +5 -4
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +206 -41
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +55 -8
  20. package/src/extension/crew-input-router.ts +9 -2
  21. package/src/extension/help.ts +21 -10
  22. package/src/extension/knowledge-injection.ts +2 -1
  23. package/src/extension/management.ts +8 -3
  24. package/src/extension/notification-sink.ts +17 -0
  25. package/src/extension/plan-orchestrate.ts +19 -3
  26. package/src/extension/registration/command-utils.ts +28 -2
  27. package/src/extension/registration/commands/dashboard.ts +11 -1
  28. package/src/extension/registration/commands/manage.ts +31 -15
  29. package/src/extension/registration/commands/run.ts +24 -2
  30. package/src/extension/registration/commands/shared.ts +34 -10
  31. package/src/extension/registration/commands/status.ts +25 -2
  32. package/src/extension/registration/context-builder.ts +8 -2
  33. package/src/extension/registration/foreground-run-controller.ts +2 -2
  34. package/src/extension/registration/health-notify-policy.ts +100 -0
  35. package/src/extension/registration/lazy-configurers.ts +38 -0
  36. package/src/extension/registration/lifecycle-handlers.ts +107 -40
  37. package/src/extension/registration/lifecycle.ts +77 -10
  38. package/src/extension/registration/observability.ts +125 -53
  39. package/src/extension/registration/registration-types.ts +7 -5
  40. package/src/extension/registration/runtime-cleanup.ts +9 -3
  41. package/src/extension/registration/subagent-helpers.ts +38 -0
  42. package/src/extension/registration/team-tool.ts +9 -2
  43. package/src/extension/registration/wire-cross-extension.ts +30 -0
  44. package/src/extension/run-compare.ts +220 -0
  45. package/src/extension/run-export.ts +37 -5
  46. package/src/extension/run-maintenance.ts +155 -5
  47. package/src/extension/team-manager-command.ts +3 -2
  48. package/src/extension/team-tool/dispatch/index.ts +3 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  50. package/src/extension/team-tool/goal-wrap.ts +7 -0
  51. package/src/extension/team-tool/goal.ts +16 -1
  52. package/src/extension/team-tool/handle-settings.ts +19 -2
  53. package/src/extension/team-tool/health-monitor.ts +21 -7
  54. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  55. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  56. package/src/extension/team-tool/plan.ts +10 -0
  57. package/src/extension/team-tool/routing-hint.ts +63 -0
  58. package/src/extension/team-tool/run-intent.ts +1 -1
  59. package/src/extension/team-tool/run.ts +1 -1
  60. package/src/extension/team-tool/status.ts +4 -0
  61. package/src/extension/team-tool.ts +52 -6
  62. package/src/extension/webhook-notify.ts +382 -0
  63. package/src/observability/metric-sink.ts +12 -2
  64. package/src/prompt/prompt-runtime.ts +82 -31
  65. package/src/prompt/worker-events-channel.ts +12 -0
  66. package/src/runtime/README.md +1 -1
  67. package/src/runtime/async-runner.ts +123 -1
  68. package/src/runtime/background-runner.ts +313 -234
  69. package/src/runtime/broker/crew-broker.ts +76 -71
  70. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  73. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  74. package/src/runtime/broker/wait-status-cache.ts +1 -1
  75. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  76. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  77. package/src/runtime/crew-agent-records.ts +337 -45
  78. package/src/runtime/deadletter.ts +43 -1
  79. package/src/runtime/delegate-spawn.ts +21 -3
  80. package/src/runtime/deterministic-ast.ts +14 -2
  81. package/src/runtime/dispatch-batch.ts +72 -5
  82. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  83. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  84. package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
  85. package/src/runtime/group-join.ts +11 -3
  86. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  87. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  88. package/src/runtime/model/model-fallback.ts +21 -1
  89. package/src/runtime/model/pi-args.ts +8 -10
  90. package/src/runtime/model/runtime-warmup.ts +2 -0
  91. package/src/runtime/output/output-validator.ts +10 -5
  92. package/src/runtime/plan-approval.ts +14 -1
  93. package/src/runtime/recovery/crash-recovery.ts +96 -49
  94. package/src/runtime/run-worker.ts +12 -1
  95. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  96. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  97. package/src/runtime/scheduling/scheduler.ts +49 -13
  98. package/src/runtime/scheduling/semaphore.ts +148 -20
  99. package/src/runtime/scratchpad/README.md +1 -1
  100. package/src/runtime/scratchpad/protocol.ts +1 -1
  101. package/src/runtime/settings-store.ts +1 -1
  102. package/src/runtime/skill-instructions.ts +22 -0
  103. package/src/runtime/stale-reconciler.ts +85 -13
  104. package/src/runtime/subagent-manager.ts +43 -13
  105. package/src/runtime/task-runner/child-executor.ts +17 -6
  106. package/src/runtime/task-runner/post-execution.ts +44 -14
  107. package/src/runtime/task-runner/pre-execution.ts +26 -2
  108. package/src/runtime/task-runner/prompt-builder.ts +150 -45
  109. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  110. package/src/runtime/task-runner.ts +28 -1
  111. package/src/runtime/team-runner.ts +42 -2
  112. package/src/runtime/verification/verification-worktree.ts +3 -0
  113. package/src/runtime/workspace-lock.ts +4 -1
  114. package/src/schema/config-schema.ts +14 -0
  115. package/src/schema/team-tool-schema.ts +17 -0
  116. package/src/state/atomic-write.ts +53 -0
  117. package/src/state/contracts.ts +109 -0
  118. package/src/state/coordination/locks.ts +191 -33
  119. package/src/state/coordination/mailbox.ts +147 -17
  120. package/src/state/crew-init.ts +87 -12
  121. package/src/state/event-log/cursor.ts +37 -1
  122. package/src/state/event-log/event-log-rotation.ts +72 -7
  123. package/src/state/stores/active-run-registry.ts +13 -1
  124. package/src/state/stores/state-store.ts +120 -23
  125. package/src/state/types.ts +4 -0
  126. package/src/ui/dashboard-panes/agents-pane.ts +22 -10
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/index.ts +1 -1
  129. package/src/ui/keybinding-map.ts +22 -4
  130. package/src/ui/live-conversation-overlay.ts +6 -3
  131. package/src/ui/render-diff.ts +19 -6
  132. package/src/ui/run-dashboard.ts +109 -6
  133. package/src/ui/run-snapshot-cache.ts +27 -4
  134. package/src/ui/spinner.ts +26 -2
  135. package/src/ui/tool-progress-formatter.ts +2 -1
  136. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  137. package/src/ui/tool-renderers/index.ts +3 -3
  138. package/src/ui/widget/task-list.ts +8 -5
  139. package/src/ui/widget/widget-model.ts +3 -1
  140. package/src/utils/incremental-reader.ts +11 -3
  141. package/src/utils/paths.ts +94 -12
  142. package/src/utils/project-markers.ts +40 -0
  143. package/src/worktree/worktree-manager.ts +206 -26
  144. package/tsconfig.json +2 -0
  145. package/workflows/distill.workflow.md +3 -3
  146. package/workflows/fast-fix.workflow.md +1 -1
  147. package/workflows/plan-execute.workflow.md +1 -1
  148. package/workflows/review.workflow.md +1 -1
  149. package/workflows/strict-fast-fix.workflow.md +1 -1
  150. package/docs/migration-v0.4-v0.5.md +0 -208
  151. package/docs/runtime-flow.md +0 -148
  152. package/src/ui/card-colors.ts +0 -168
  153. package/src/utils/completion-dedupe.ts +0 -63
  154. package/src/utils/resolve-shell.ts +0 -36
@@ -21,7 +21,7 @@ import {
21
21
  import { canTransitionRunStatus } from "../contracts.ts";
22
22
  import { withRunLock, withRunLockSync } from "../coordination/locks.ts";
23
23
  import { appendEvent } from "../event-log/event-log.ts";
24
- import type { TeamRunManifest, TeamTaskState } from "../types.ts";
24
+ import type { RunModelContext, TeamRunManifest, TeamTaskState } from "../types.ts";
25
25
  import { CURRENT_SCHEMA_VERSION } from "../types.ts";
26
26
  import { unregisterActiveRun } from "./active-run-registry.ts";
27
27
  import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
@@ -193,19 +193,37 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
193
193
  const now = Date.now();
194
194
  const cached = runStateRootCache.get(key);
195
195
  if (cached && cached.expiresAt > now) return cached.root;
196
- const runsRoot = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.runsSubdir);
197
- const scopedPath = resolveContainedRelativePath(runsRoot, runId, "runId");
198
- try {
199
- resolveRealContainedPath(runsRoot, runId);
200
- } catch {
201
- return undefined;
202
- }
203
- if (runStateRootCache.size >= RUN_STATE_ROOT_CACHE_MAX) {
204
- const oldest = runStateRootCache.keys().next().value;
205
- if (oldest !== undefined) runStateRootCache.delete(oldest);
196
+ // F-L1 (2026-09-22): listing (run-index scopedRunRoots) UNIONS the user and
197
+ // project run roots, but this resolution used scopeBaseRoot's single XOR
198
+ // pick — a run living under the OTHER root (e.g. created by another cwd or
199
+ // before the repo got a marker) listed fine yet FAILED every by-ID lookup:
200
+ // team status / prune / forget / scheduler provenance all returned "not
201
+ // found". Try the primary root first (hot path unchanged — one contained
202
+ // resolve + cache hit), then fall back to the other root. The no-repo case
203
+ // never falls back to a project path, mirroring scopedRunRoots' `if
204
+ // (projectRoot)` guard — resolution sees exactly what listing sees.
205
+ const candidates = useProjectState(cwd) ? [projectCrewRoot(cwd), userCrewRoot()] : [userCrewRoot()];
206
+ for (const root of candidates) {
207
+ const runsRoot = path.join(root, DEFAULT_PATHS.state.runsSubdir);
208
+ const scopedPath = resolveContainedRelativePath(runsRoot, runId, "runId");
209
+ // resolveRealContainedPath deliberately ACCEPTS a missing target (write-path
210
+ // semantics: ENOENT is fine, only symlink/escape violations throw) — so
211
+ // existence must be probed explicitly. Without this, the primary root
212
+ // always "wins" with a phantom path and the fallback can never run.
213
+ if (!fs.existsSync(path.join(scopedPath, DEFAULT_PATHS.state.manifestFile))) continue;
214
+ try {
215
+ resolveRealContainedPath(runsRoot, runId);
216
+ } catch {
217
+ continue; // symlink/escape violation under this root — try the other
218
+ }
219
+ if (runStateRootCache.size >= RUN_STATE_ROOT_CACHE_MAX) {
220
+ const oldest = runStateRootCache.keys().next().value;
221
+ if (oldest !== undefined) runStateRootCache.delete(oldest);
222
+ }
223
+ runStateRootCache.set(key, { root: scopedPath, expiresAt: now + RUN_STATE_ROOT_TTL_MS });
224
+ return scopedPath;
206
225
  }
207
- runStateRootCache.set(key, { root: scopedPath, expiresAt: now + RUN_STATE_ROOT_TTL_MS });
208
- return scopedPath;
226
+ return undefined;
209
227
  }
210
228
 
211
229
  // PERF (2026-08-24): the artifacts containment verdict (existsSync + lstat +
@@ -239,7 +257,13 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
239
257
  manifest.eventsPath !== path.join(stateRoot, "events.jsonl")
240
258
  )
241
259
  return false;
242
- const artifactsParent = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.artifactsSubdir);
260
+ // F-L1 (2026-09-22): derive the artifacts parent from the RESOLVED stateRoot's
261
+ // base root (…/<base>/state/runs/<runId>), not scopeBaseRoot — cross-root
262
+ // runs (state under the fallback root, artifacts written beside it at
263
+ // creation) must validate against their OWN root; scopeBaseRoot would
264
+ // reject every fallback-resolved run and keep it invisible by ID.
265
+ const baseRoot = path.resolve(stateRoot, "..", "..", "..");
266
+ const artifactsParent = path.join(baseRoot, DEFAULT_PATHS.state.artifactsSubdir);
243
267
  const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
244
268
  if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
245
269
  // PERF (2026-08-24): memoized verdict — see artifactsVerdictCache above.
@@ -409,9 +433,20 @@ export function createRunManifest(params: {
409
433
  runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
410
434
  /** round-14 P1-5: typed workflow arguments for .dwf.ts scripts (ctx.args<T>()). */
411
435
  args?: unknown;
436
+ /** Deterministic-capture support (2026-09-22): pin the run ID and/or clock
437
+ * so tools like docs/ui-samples/capture.ts produce byte-stable output.
438
+ * Defaults preserve current behavior (generated id, wall clock). */
439
+ runId?: string;
440
+ now?: () => Date;
441
+ /** Model routing snapshot (Finding #1, battery 2026-09-29): detached runs
442
+ * re-enter through background-runner with no ExtensionContext, so the
443
+ * caller's model override / inherited session model / auth-filtered
444
+ * catalogue must be persisted on the manifest at creation time.
445
+ * Omitted when undefined so old manifests stay byte-identical. */
446
+ modelContext?: RunModelContext;
412
447
  }): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
413
- const paths = createRunPaths(params.cwd);
414
- const now = new Date().toISOString();
448
+ const paths = createRunPaths(params.cwd, params.runId);
449
+ const now = (params.now ? params.now() : new Date()).toISOString();
415
450
  const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
416
451
  const manifest: TeamRunManifest = {
417
452
  schemaVersion: CURRENT_SCHEMA_VERSION,
@@ -433,6 +468,7 @@ export function createRunManifest(params: {
433
468
  ...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
434
469
  runKind: params.runKind ?? "team-run",
435
470
  ...(params.args !== undefined ? { args: params.args } : {}),
471
+ ...(params.modelContext ? { modelContext: params.modelContext } : {}),
436
472
  };
437
473
  fs.mkdirSync(paths.stateRoot, { recursive: true });
438
474
  fs.mkdirSync(paths.artifactsRoot, { recursive: true });
@@ -479,7 +515,12 @@ export function createRunManifest(params: {
479
515
  return { manifest, tasks, paths };
480
516
  }
481
517
 
482
- export function saveRunManifest(manifest: TeamRunManifest): void {
518
+ export function saveRunManifest(manifest: TeamRunManifest, options?: { allowTerminalExit?: boolean }): TeamRunManifest {
519
+ // Finding 8 (2026-09-23): terminal-preserve at the WRITE layer. Mid-flight
520
+ // savers (task-runner artifact/progress writes carrying a stale in-memory
521
+ // "running" manifest) previously overwrote an externally-written terminal
522
+ // status; merge/finalize had their own guards but every other save site did
523
+ // not. Guard here so NO raw save can erase a terminal disk status.
483
524
  // FIX: Capture the cached tasks array + mtime/size BEFORE we invalidate the
484
525
  // cache. The previous implementation re-read tasks.json from disk after the
485
526
  // manifest write (a JSON.parse + fs.readFileSync per call), which defeated
@@ -502,12 +543,13 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
502
543
  // which is always safe.
503
544
  invalidateRunCache(manifest.stateRoot);
504
545
  const manifestPath = path.join(manifest.stateRoot, "manifest.json");
546
+ const effective = preserveDiskTerminalStatus(manifest, options?.allowTerminalExit);
505
547
  // REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion —
506
548
  // saveRunManifest is a SYNCHRONOUS persist by name/contract (tests assert
507
549
  // it, broker loadRunManifestById is a cross-process reader, and the
508
550
  // statSync-based cache repopulation below needs the real post-write
509
551
  // mtime/size). The 50ms coalesce window broke all three.
510
- atomicWriteJson(manifestPath, manifest);
552
+ atomicWriteJson(manifestPath, effective);
511
553
  // FIX: Re-populate cache with actual mtime/size so loadRunManifestById
512
554
  // doesn't miss the cache on next read. Without this, every load until
513
555
  // TTL expires would hit disk because cached 0 !== any real mtime.
@@ -516,16 +558,17 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
516
558
  // fresh tasks should call saveRunTasks or loadRunTasks separately.
517
559
  const manifestStat = fs.statSync(manifestPath);
518
560
  setManifestCache(manifest.stateRoot, {
519
- manifest,
561
+ manifest: effective,
520
562
  tasks: cachedTasks,
521
563
  manifestMtimeMs: manifestStat.mtimeMs,
522
564
  manifestSize: manifestStat.size,
523
565
  tasksMtimeMs: cachedTasksMtimeMs,
524
566
  tasksSize: cachedTasksSize,
525
567
  });
568
+ return effective;
526
569
  }
527
570
 
528
- export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<void> {
571
+ export async function saveRunManifestAsync(manifest: TeamRunManifest, options?: { allowTerminalExit?: boolean }): Promise<TeamRunManifest> {
529
572
  // FIX: Capture cached tasks array + mtime/size BEFORE invalidating, same
530
573
  // rationale as the sync saveRunManifest above. The async path previously
531
574
  // always set tasks: [] with mtime/size 0, so any cache hit was guaranteed
@@ -538,7 +581,8 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<v
538
581
  // after a crash. See saveRunManifest for full explanation.
539
582
  invalidateRunCache(manifest.stateRoot);
540
583
  const manifestPath = path.join(manifest.stateRoot, "manifest.json");
541
- await atomicWriteJsonAsync(manifestPath, manifest);
584
+ const effective = preserveDiskTerminalStatus(manifest, options?.allowTerminalExit);
585
+ await atomicWriteJsonAsync(manifestPath, effective);
542
586
  // FIX: Re-populate cache with actual mtime/size. See saveRunManifest.
543
587
  // RACE GUARD: another concurrent async save (OPT-02) may unlink+rewrite
544
588
  // manifest.json between our atomicWriteJsonAsync and this stat. If stat
@@ -553,13 +597,14 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<v
553
597
  manifestStat = { mtimeMs: 0, size: 0 };
554
598
  }
555
599
  setManifestCache(manifest.stateRoot, {
556
- manifest,
600
+ manifest: effective,
557
601
  tasks: cachedTasks,
558
602
  manifestMtimeMs: manifestStat.mtimeMs,
559
603
  manifestSize: manifestStat.size,
560
604
  tasksMtimeMs: cachedTasksMtimeMs,
561
605
  tasksSize: cachedTasksSize,
562
606
  });
607
+ return effective;
563
608
  }
564
609
 
565
610
  /**
@@ -803,6 +848,44 @@ function saveManifestAndTasksAtomicSync(manifest: TeamRunManifest, tasks: TeamTa
803
848
  export interface UpdateRunStatusOptions {
804
849
  data?: Record<string, unknown>;
805
850
  metadata?: Parameters<typeof appendEvent>[1]["metadata"];
851
+ /** Finding 8 (2026-09-23): allow leaving a TERMINAL disk status (resume is the
852
+ * only legitimate terminal-exit flow). Defaults to false — a raw save with an
853
+ * in-memory "running" manifest must never erase an externally-written
854
+ * cancelled/failed/completed status (live race team_20260923175042: cancel
855
+ * 17:51:08 → intermediate task-runner save re-wrote "running" → finalize
856
+ * completed — the cancel was fully erased). */
857
+ allowTerminalExit?: boolean;
858
+ }
859
+
860
+ /** Finding 8: statuses a disk write must never silently leave. Mirrors
861
+ * isRunTerminalPreserved (merge-loop.ts) — kept local to avoid a runtime dep
862
+ * from the store layer to the runtime layer. */
863
+ const DISK_TERMINAL_STATUSES: ReadonlySet<TeamRunManifest["status"]> = new Set(["cancelled", "failed", "completed"]);
864
+
865
+ /** Finding 8 write-layer guard: if the DISK manifest is terminal and the
866
+ * incoming write carries a NON-terminal status (the erase class — a mid-flight
867
+ * saver with a stale in-memory "running" manifest), preserve the disk
868
+ * status/summary/updatedAt while keeping every other incoming field (artifacts,
869
+ * usage, surface…). Terminal→terminal re-decisions (e.g. cancelling a run that
870
+ * just completed — pinned by resume-cancel.test.ts) are LEGITIMATE and pass
871
+ * through; they are governed by canTransitionRunStatus at the updateRunStatus
872
+ * layer. Returns the effective manifest to persist. */
873
+ function preserveDiskTerminalStatus(manifest: TeamRunManifest, allowTerminalExit: boolean | undefined): TeamRunManifest {
874
+ if (allowTerminalExit) return manifest;
875
+ // Terminal→terminal re-decisions pass through (governed by
876
+ // canTransitionRunStatus at the updateRunStatus layer); the guard applies
877
+ // ONLY to the erase class: a NON-terminal incoming status over terminal disk.
878
+ if (DISK_TERMINAL_STATUSES.has(manifest.status)) return manifest;
879
+ try {
880
+ const manifestPath = path.join(manifest.stateRoot, "manifest.json");
881
+ // Raw read (no cache): cross-process cancel writes must be seen NOW.
882
+ const raw = fs.readFileSync(manifestPath, "utf-8");
883
+ const disk = JSON.parse(raw) as TeamRunManifest;
884
+ if (!DISK_TERMINAL_STATUSES.has(disk.status) || disk.status === manifest.status) return manifest;
885
+ return { ...manifest, status: disk.status, summary: disk.summary, updatedAt: disk.updatedAt };
886
+ } catch {
887
+ return manifest; // no disk manifest yet (create) or unreadable — normal write
888
+ }
806
889
  }
807
890
 
808
891
  export function updateRunStatus(
@@ -820,7 +903,21 @@ export function updateRunStatus(
820
903
  updatedAt: new Date().toISOString(),
821
904
  summary: summary ?? manifest.summary,
822
905
  };
823
- saveRunManifest(updated);
906
+ // Finding 8 (2026-09-23): the write-layer guard may PRESERVE a terminal disk
907
+ // status (an external cancel/reconciler write the in-memory manifest never
908
+ // saw). In that case do NOT emit run.<status>, do NOT flip the manifest —
909
+ // record the refusal and return the preserved state. Resume passes
910
+ // allowTerminalExit for its legitimate cancelled→running transition.
911
+ const saved = saveRunManifest(updated, { allowTerminalExit: options.allowTerminalExit });
912
+ if (saved.status !== status) {
913
+ appendEvent(saved.eventsPath, {
914
+ type: "run.terminal_preserved",
915
+ runId: saved.runId,
916
+ message: `Preserved terminal status '${saved.status}'; refused in-memory transition to '${status}'.`,
917
+ data: { preserved: saved.status, refused: status },
918
+ });
919
+ return saved;
920
+ }
824
921
  // Unregister from active-run-index when run reaches a terminal status.
825
922
  // Without this, stale entries accumulate (e.g. integration tests in /tmp) and
826
923
  // Pi UI shows ghost "queued" runs that are actually completed/failed/cancelled.
@@ -543,6 +543,10 @@ export interface GoalLoopState {
543
543
  nextTurnFeedback?: string;
544
544
  /** The team-run of the current in-flight turn (for cancel/steer). */
545
545
  currentRunId?: string;
546
+ /** GL-1b (2026-09-22): why the last turn (or the loop itself) failed —
547
+ * persisted at goal level because turn-run dirs are pruned (keep=10 at
548
+ * session start) and otherwise the reason becomes unrecoverable. */
549
+ lastTurnError?: string;
546
550
  verdicts: GoalVerdict[];
547
551
  history: {
548
552
  runId: string;
@@ -116,13 +116,16 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
116
116
  // One pinned clock (D6-T4) for the whole pane render.
117
117
  const nowMs = options.nowMs ?? Date.now();
118
118
 
119
- const realAgents = snapshot.agents.filter((a) =>
120
- isRealAgent(
121
- a,
122
- liveForRun.find((h) => h.taskId === a.taskId),
123
- nowMs,
124
- ),
125
- );
119
+ // RR-021 WI-4.3c: first-wins Map keyed by taskId — the two `.find` sites
120
+ // below (real-agent filter + per-agent handle) must keep seeing the SAME
121
+ // (first) matching handle, so the build guards with has() instead of set()
122
+ // overwriting later duplicates.
123
+ const liveByTaskId = new Map<string, (typeof liveForRun)[number]>();
124
+ for (const handle of liveForRun) {
125
+ if (!liveByTaskId.has(handle.taskId)) liveByTaskId.set(handle.taskId, handle);
126
+ }
127
+
128
+ const realAgents = snapshot.agents.filter((a) => isRealAgent(a, liveByTaskId.get(a.taskId), nowMs));
126
129
  const lineCount = Math.min(realAgents.length, 12);
127
130
  const label =
128
131
  realAgents.length !== snapshot.agents.length
@@ -135,7 +138,7 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
135
138
  lines.push(`${completed}/${total} tasks · ${label}`);
136
139
 
137
140
  for (const agent of realAgents.slice(0, 12)) {
138
- const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
141
+ const liveHandle = liveByTaskId.get(agent.taskId);
139
142
  const icon = agentGlyph(agent.status, agent.taskId);
140
143
  const role = `${agent.role ?? "?"}`;
141
144
 
@@ -178,8 +181,17 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
178
181
  stats.push(liveHandle.modelName);
179
182
  }
180
183
  } else if (agent.startedAt) {
181
- const ms = nowMs - new Date(agent.startedAt).getTime();
182
- if (Number.isFinite(ms)) stats.push(alignMetric(formatDuration(ms), DURATION_METRIC_WIDTH));
184
+ // Tier 13 (2026-09-21): a FINISHED agent's span must end at completedAt,
185
+ // never at the wall clock. The naive `nowMs - startedAt` made every
186
+ // completed agent of an old run inflate forever — the dashboard showed
187
+ // `01_explore … 29m39s` while the tool card showed the real `8m22s` for
188
+ // the same agent. Mirror computeLiveDurationMs: prefer a sane completedAt,
189
+ // else fall back to now; never emit a negative or absurd span.
190
+ const startedMs = new Date(agent.startedAt).getTime();
191
+ const rawCompleted = agent.completedAt ? new Date(agent.completedAt).getTime() : Number.NaN;
192
+ const completedMs = Number.isFinite(rawCompleted) && rawCompleted >= startedMs && rawCompleted <= nowMs ? rawCompleted : nowMs;
193
+ const ms = completedMs - startedMs;
194
+ if (Number.isFinite(ms) && ms >= 0) stats.push(alignMetric(formatDuration(ms), DURATION_METRIC_WIDTH));
183
195
  }
184
196
 
185
197
  const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
@@ -32,6 +32,32 @@ function isActiveTask(task: TeamTaskState): boolean {
32
32
  return task.status === "running";
33
33
  }
34
34
 
35
+ /**
36
+ * FINDING 5 (2026-09-23 battery): health ticks must count task statuses from
37
+ * DISK truth, not the (possibly lagging) snapshot cache. A worker parked on
38
+ * `ask` transitions running → waiting on disk while the cached snapshot can
39
+ * still show `running` — in that window the parked worker counted as
40
+ * active-without-heartbeat and fired a false "dead worker" notification (live:
41
+ * run team_20260923100114, 01_explore parked on ask, later answered+resumed).
42
+ *
43
+ * Overlay FRESH task statuses (and heartbeats) from disk onto the cached
44
+ * snapshot before summarizing. Returns the ORIGINAL snapshot object when
45
+ * nothing diverged (cheap identity check — callers use it to decide cache
46
+ * invalidation), or a shallow copy with the diverging tasks replaced.
47
+ */
48
+ export function overlayFreshTaskStatuses(snapshot: RunUiSnapshot, freshTasks: TeamTaskState[]): RunUiSnapshot {
49
+ if (freshTasks.length === 0) return snapshot;
50
+ const freshById = new Map(freshTasks.map((task) => [task.id, task]));
51
+ let diverged = false;
52
+ const tasks = snapshot.tasks.map((task) => {
53
+ const fresh = freshById.get(task.id);
54
+ if (!fresh || (fresh.status === task.status && fresh.heartbeat === task.heartbeat)) return task;
55
+ diverged = true;
56
+ return { ...task, status: fresh.status, heartbeat: fresh.heartbeat };
57
+ });
58
+ return diverged ? { ...snapshot, tasks } : snapshot;
59
+ }
60
+
35
61
  export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSummaryOptions = {}): HeartbeatSummary {
36
62
  const staleMs = opts.staleMs ?? 60_000;
37
63
  const deadMs = opts.deadMs ?? 5 * 60_000;
@@ -55,6 +81,14 @@ export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSumm
55
81
  const runTerminal = isTerminalRunStatus(snapshot.manifest.status);
56
82
  for (const task of snapshot.tasks) {
57
83
  if (runTerminal || !isActiveTask(task)) continue;
84
+ // Guest-child tasks (delegate subagents, agent === "delegate") have no
85
+ // heartbeat channel — they never write task.heartbeat and complete in
86
+ // seconds; their lifecycle is owned by delegate.requested/admitted/
87
+ // completed broker events. Counting them here fired a false "N worker(s)
88
+ // missing heartbeat" ambient for every delegate-using run (live:
89
+ // team_20260926033657_2b6c6d2610b26d9d, 2026-09-26 battery — same
90
+ // gc-blindness root shape as the heartbeat-watcher fix).
91
+ if (task.agent === "delegate") continue;
58
92
  const heartbeat = task.heartbeat;
59
93
  if (!heartbeat) {
60
94
  summary.missing += 1;
@@ -277,7 +277,7 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
277
277
  // (lazy import: viewers.ts pulls the state-store chain and must stay
278
278
  // off this module's startup path — AGENTS.md lazy boundary).
279
279
  onOpenBrowser: () => {
280
- void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx));
280
+ void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx)); // LAZY: defer agents-jobs browser until opened
281
281
  },
282
282
  });
283
283
  });
@@ -33,6 +33,7 @@ import * as fs from "node:fs";
33
33
  import * as path from "node:path";
34
34
  import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
35
35
  import { getCrewEnv } from "../config/env-vars.ts";
36
+ import { projectCrewRoot } from "../utils/paths.ts";
36
37
  import { keyOf } from "./key-utils.ts";
37
38
 
38
39
  export const DASHBOARD_KEYS = {
@@ -41,6 +42,8 @@ export const DASHBOARD_KEYS = {
41
42
  help: ["?"],
42
43
  root: {
43
44
  summary: ["u"],
45
+ /** US-020: cancel the selected run (2-step confirm in the dashboard). */
46
+ cancel: ["x"],
44
47
  artifacts: ["a"],
45
48
  api: ["i"],
46
49
  agents: ["d"],
@@ -112,6 +115,7 @@ export type DashboardKeyAction =
112
115
  | "live-conversation"
113
116
  | "reload"
114
117
  | "browser"
118
+ | "cancel"
115
119
  | "pane-agents"
116
120
  | "pane-progress"
117
121
  | "pane-mailbox"
@@ -256,6 +260,9 @@ const DEFAULT_BINDINGS: readonly KeyBinding[] = [
256
260
  // safe; inside the browser overlay itself "p" is free because overlays
257
261
  // are mutually exclusive (see keybinding-map.ts header note).
258
262
  { keys: DASHBOARD_KEYS.root.browser, action: "browser" },
263
+ // US-020: x → cancel the selected run (unscoped; the schedules pane's X
264
+ // delete is pane-scoped uppercase — pass-1 exact match keeps them distinct).
265
+ { keys: DASHBOARD_KEYS.root.cancel, action: "cancel" },
259
266
  { keys: DASHBOARD_KEYS.pane.agents, action: "pane-agents" },
260
267
  { keys: DASHBOARD_KEYS.pane.progress, action: "pane-progress" },
261
268
  { keys: DASHBOARD_KEYS.pane.mailbox, action: "pane-mailbox" },
@@ -418,7 +425,10 @@ export type KeybindingOverride = Partial<Record<DashboardKeyAction | OverlayBind
418
425
  const KEYBINDINGS_ENV = "PI_CREW_KEYBINDINGS";
419
426
 
420
427
  /** Every dispatched action + every `overlay:*` binding is a valid override target. */
421
- const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set<string>([...DEFAULT_BINDINGS.map((b) => b.action), ...OVERLAY_BINDING_KEYS]);
428
+ export const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set<string>([
429
+ ...DEFAULT_BINDINGS.map((b) => b.action),
430
+ ...OVERLAY_BINDING_KEYS,
431
+ ]);
422
432
 
423
433
  /** Coerce an unknown parsed value into a safe {@link KeybindingOverride}. */
424
434
  function parseKeybindingOverride(raw: unknown): KeybindingOverride {
@@ -541,10 +551,17 @@ function computeEffectiveBindings(overrides: KeybindingOverride): EffectiveBindi
541
551
  return { bindings, overlayBindings, reverted: [...reverted] };
542
552
  }
543
553
 
544
- /** Read the `keybindings` section from `<cwd>/.crew/config.json`. */
554
+ /**
555
+ * Read the `keybindings` section from the project config.
556
+ *
557
+ * RR-020 Fix 4: resolve via `projectCrewRoot(cwd)` instead of a literal
558
+ * `<cwd>/.crew` — the config lives in whichever layout the project actually
559
+ * uses (`.crew/` or `.pi/teams/`), so a `.pi/teams` project's keybinding
560
+ * overrides are no longer silently ignored.
561
+ */
545
562
  function readConfigKeybindings(cwd: string): KeybindingOverride {
546
563
  try {
547
- const raw: unknown = JSON.parse(fs.readFileSync(path.join(cwd, ".crew", "config.json"), "utf-8"));
564
+ const raw: unknown = JSON.parse(fs.readFileSync(path.join(projectCrewRoot(cwd), "config.json"), "utf-8"));
548
565
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
549
566
  return parseKeybindingOverride((raw as Record<string, unknown>).keybindings);
550
567
  } catch {
@@ -565,7 +582,8 @@ function readEnvKeybindings(): KeybindingOverride {
565
582
 
566
583
  function configKeybindingsMtime(cwd: string): number | undefined {
567
584
  try {
568
- return fs.statSync(path.join(cwd, ".crew", "config.json")).mtimeMs;
585
+ // RR-020 Fix 4: same layout-resolved config path as readConfigKeybindings.
586
+ return fs.statSync(path.join(projectCrewRoot(cwd), "config.json")).mtimeMs;
569
587
  } catch {
570
588
  return undefined;
571
589
  }
@@ -62,11 +62,14 @@ export class LiveConversationOverlay {
62
62
  private handle: LiveAgentHandle;
63
63
  private theme: CrewTheme;
64
64
 
65
- constructor(handle: LiveAgentHandle, theme: CrewTheme, columns = 80, rows = 24) {
65
+ private readonly nowMs: () => number;
66
+
67
+ constructor(handle: LiveAgentHandle, theme: CrewTheme, columns = 80, rows = 24, nowMs?: () => number) {
66
68
  this.handle = handle;
67
69
  this.theme = theme;
68
70
  this.columns = columns;
69
71
  this.rows = rows;
72
+ this.nowMs = nowMs ?? Date.now;
70
73
  // R8: Subscribe to real session events if available
71
74
  const session = handle.session as Record<string, unknown>;
72
75
  if (typeof session.subscribe === "function") {
@@ -117,7 +120,7 @@ export class LiveConversationOverlay {
117
120
 
118
121
  private refreshSummary(): void {
119
122
  const act = this.handle.activity;
120
- const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${(computeLiveDurationMs(act) / 1000).toFixed(1)}s]`;
123
+ const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s]`;
121
124
  const lastLine = this.cachedLines[this.cachedLines.length - 1];
122
125
  if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
123
126
  this.cachedLines[this.cachedLines.length - 1] = summary;
@@ -267,7 +270,7 @@ export class LiveConversationOverlay {
267
270
  if (act.maxTurns != null) parts.push(`turn ${act.turnCount ?? 0}/${act.maxTurns}`);
268
271
  else if ((act.turnCount ?? 0) > 0) parts.push(`turn ${act.turnCount}`);
269
272
  if ((act.toolUses ?? 0) > 0) parts.push(formatCount(act.toolUses ?? 0, "tool"));
270
- parts.push(`${(computeLiveDurationMs(act) / 1000).toFixed(1)}s`);
273
+ parts.push(`${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s`);
271
274
  try {
272
275
  const ctxPct = this.handle.session.getSessionStats?.()?.contextUsage?.percent;
273
276
  if (ctxPct != null) {
@@ -1,7 +1,20 @@
1
- import * as Diff from "diff";
1
+ import { createRequire } from "node:module";
2
+ import type { Change } from "diff";
2
3
  import type { CrewTheme } from "./theme-adapter.ts";
3
4
  import { asCrewTheme } from "./theme-adapter.ts";
4
5
 
6
+ // RR-021 WI-2.1: the `diff` package was eagerly imported module-scope with a
7
+ // wildcard (pulling the whole namespace into every UI load). Only diffWords is
8
+ // used at runtime — lazy sync accessor on first use (model: ui/syntax-highlight.ts).
9
+ // `Change` is a type-only import (erased at compile time, zero runtime cost).
10
+ const require = createRequire(import.meta.url);
11
+ type DiffModule = typeof import("diff");
12
+ let cachedDiff: DiffModule | undefined;
13
+ function diffModule(): DiffModule {
14
+ if (!cachedDiff) cachedDiff = require("diff") as DiffModule;
15
+ return cachedDiff;
16
+ }
17
+
5
18
  interface ParsedDiffLine {
6
19
  prefix: string;
7
20
  lineNum: string;
@@ -36,8 +49,8 @@ const WORD_DIFF_MIN_SIM = 0.15;
36
49
  * characters relative to the longer of the two strings, using word-level
37
50
  * diff to identify the common (unchanged) parts. Returns a value in [0, 1].
38
51
  */
39
- function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Diff.Change[]): number {
40
- const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
52
+ function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Change[]): number {
53
+ const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
41
54
  let commonChars = 0;
42
55
  for (const part of parts) {
43
56
  if (!part.removed && !part.added) {
@@ -53,9 +66,9 @@ function renderIntraLineDiff(
53
66
  theme: CrewTheme,
54
67
  oldContent: string,
55
68
  newContent: string,
56
- wordDiff?: Diff.Change[],
69
+ wordDiff?: Change[],
57
70
  ): { removedLine: string; addedLine: string } {
58
- const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
71
+ const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
59
72
  let removedLine = "";
60
73
  let addedLine = "";
61
74
  let isFirstRemoved = true;
@@ -137,7 +150,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
137
150
  const newContent = replaceTabs(addedLines[0]!.content);
138
151
  // PERF (2026-08-24): diffWords is the expensive call and was run
139
152
  // TWICE on identical inputs (similarity + render). One pass, shared.
140
- const wordDiff = Diff.diffWords(oldContent, newContent);
153
+ const wordDiff = diffModule().diffWords(oldContent, newContent);
141
154
  const similarity = computeSimilarity(oldContent, newContent, wordDiff);
142
155
  if (similarity >= WORD_DIFF_MIN_SIM) {
143
156
  const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent, wordDiff);