pi-crew 0.9.41 → 0.9.44

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 (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -1,7 +1,9 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { randomUUID, timingSafeEqual } from "node:crypto";
2
3
  import * as fs from "node:fs";
3
4
  import * as path from "node:path";
4
5
  import { DEFAULT_LOCKS } from "../config/defaults.ts";
6
+ import { logInternalError } from "../utils/internal-error.ts";
5
7
  import { sleepSync } from "../utils/sleep.ts";
6
8
  import { isSymlinkSafePath } from "./atomic-write.ts";
7
9
  import type { TeamRunManifest } from "./types.ts";
@@ -103,41 +105,56 @@ function isLockHolderAlive(filePath: string): boolean {
103
105
  *
104
106
  * See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
105
107
  */
106
- function readLockSnapshot(filePath: string, staleMs: number): { canSteal: boolean } {
108
+ function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOwnPidAsStealable?: boolean }): { canSteal: boolean } {
109
+ const treatOwnPidAsStealable = options?.treatOwnPidAsStealable === true;
107
110
  let stat: fs.Stats | undefined;
108
111
  let raw: string | undefined;
109
112
  try {
110
113
  stat = fs.statSync(filePath);
111
114
  raw = fs.readFileSync(filePath, "utf-8");
112
- } catch {
113
- // File vanished between writeLockFile's EEXIST and now (holder released).
114
- // Loop will retry the create; safe to signal "nothing to steal".
115
+ } catch (error) {
116
+ // FIX (CI flake): when the lock file vanishes between writeLockFile's
117
+ // EEXIST and our read here, the holder has released — return canSteal:true
118
+ // so the acquire loop retries the create instead of throwing a false
119
+ // "locked" error.
120
+ const code = (error as NodeJS.ErrnoException).code;
121
+ if (code === "ENOENT") {
122
+ return { canSteal: true };
123
+ }
124
+ // Transient I/O error — be conservative (don't steal, retry the read on
125
+ // next attempt).
115
126
  return { canSteal: false };
116
127
  }
117
128
  // Staleness from a single snapshot.
118
129
  let createdAt = parseCreatedAtFromLock(raw);
119
130
  if (createdAt === undefined) createdAt = stat.mtimeMs;
120
131
  const isStale = Date.now() - createdAt > staleMs;
121
- // Holder liveness from the SAME snapshot.
122
- let isAlive = true; // Unknown holder — assume alive to be safe (matches isLockHolderAlive).
132
+ let holderPid: number | undefined;
133
+ let isAlive = true;
123
134
  try {
124
135
  const parsed = JSON.parse(raw) as { pid?: unknown };
125
- const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
126
- if (pid !== undefined) {
127
- try {
128
- process.kill(pid, 0);
129
- isAlive = true;
130
- } catch (error) {
131
- const code = (error as NodeJS.ErrnoException).code;
132
- // EPERM/ESRCH → treat as not-alive (stealable), see isLockHolderAlive.
133
- isAlive = false;
134
- }
135
- }
136
+ holderPid = typeof parsed.pid === "number" ? parsed.pid : undefined;
136
137
  } catch {
137
138
  /* malformed payload — keep isAlive=true */
138
139
  }
139
- // Steal if stale OR holder dead — matches the original intent.
140
- return { canSteal: isStale || !isAlive };
140
+ if (holderPid !== undefined) {
141
+ try {
142
+ process.kill(holderPid, 0);
143
+ isAlive = true;
144
+ } catch (error) {
145
+ const code = (error as NodeJS.ErrnoException).code;
146
+ // EPERM/ESRCH → treat as not-alive (stealable), see isLockHolderAlive.
147
+ isAlive = false;
148
+ }
149
+ }
150
+ // Steal if stale OR holder dead. Optionally steal if holder is our own pid
151
+ // (used by withRunLock* which is single-process — a fresh lock with our pid
152
+ // between acquisitions is just a leftover from a previous call that
153
+ // releaseOwnLock didn't get to delete yet; safe to steal).
154
+ const isOurOwnHolder = holderPid === process.pid;
155
+ return {
156
+ canSteal: isStale || !isAlive || (treatOwnPidAsStealable && isOurOwnHolder),
157
+ };
141
158
  }
142
159
 
143
160
  /**
@@ -221,6 +238,34 @@ function timingSafeTokenMatch(a: string, b: string): boolean {
221
238
  return timingSafeEqual(bufA, bufB);
222
239
  }
223
240
 
241
+ /**
242
+ * Release the lock we (this process) just acquired. Unlike releaseLock, this
243
+ * unconditionally removes the file because we know we created it in the same
244
+ * withRunLock* call — token matching is only needed for multi-process scenarios
245
+ * (where a different holder's token must not be deleted). Within the same process
246
+ * the new acquire uses a fresh randomUUID token, so token matching would falsely
247
+ * fail and leak the file across releases.
248
+ *
249
+ * Symlink guard is preserved: if a symlink appeared since our writeLockFile, we
250
+ * don't rm it (defense against attacker-planted symlinks).
251
+ */
252
+ export function releaseOwnLock(filePath: string, _token: string): void {
253
+ try {
254
+ const stat = fs.lstatSync(filePath);
255
+ if (stat.isSymbolicLink()) return;
256
+ } catch {
257
+ /* ENOENT is fine */
258
+ }
259
+ try {
260
+ fs.rmSync(filePath, { force: true });
261
+ } catch (error) {
262
+ const code = (error as NodeJS.ErrnoException).code;
263
+ if (code !== "ENOENT") {
264
+ logInternalError("lock-release-own", error, filePath, "warn");
265
+ }
266
+ }
267
+ }
268
+
224
269
  function releaseLock(filePath: string, token: string): void {
225
270
  // FIX: Do not delete a symlink — it may have been planted by an attacker
226
271
  // after our lock was released. A legitimate lock file should never be a
@@ -265,7 +310,9 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
265
310
  }
266
311
  // Round 26 (BUG 1): single-snapshot read closes the TOCTOU window between
267
312
  // separate stale + alive reads (which could race stale→fresh).
268
- const { canSteal } = readLockSnapshot(filePath, staleMs);
313
+ // treatOwnPidAsStealable=false (this is the sync file-lock path used
314
+ // by withFileLockSync — never steal a fresh lock even if it has our pid).
315
+ const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: false });
269
316
  if (!canSteal) {
270
317
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
271
318
  }
@@ -300,7 +347,13 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
300
347
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
301
348
  }
302
349
  // Round 26 (BUG 1): single-snapshot read (see sync variant).
303
- const { canSteal } = readLockSnapshot(filePath, staleMs);
350
+ // FIX (CI flake): treatOwnPidAsStealable=true for the run-lock async path
351
+ // (withRunLock). Between acquisitions in parallel-research scaffold mode,
352
+ // the previous releaseOwnLock leaves a microsecond window where the
353
+ // file still exists with our own pid. Stealing it avoids spurious 'locked'
354
+ // errors. The sync file-lock path (withFileLockSync) above uses false to
355
+ // preserve the multi-process safety guarantee.
356
+ const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: true });
304
357
  if (!canSteal) {
305
358
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
306
359
  }
@@ -379,48 +432,78 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
379
432
  }
380
433
  }
381
434
 
435
+ // H-1: track re-entrant run-lock acquisitions PER ASYNC CONTEXT (not process-global).
436
+ // Previously a module-global Map caused a bug: when withRunLock (async) yielded at
437
+ // `await fn()`, a concurrent withRunLockSync for the same run — fired from a
438
+ // different async context (e.g. a child stdout event handler) — saw the holder's
439
+ // token and bypassed the file lock entirely, allowing two writers in the critical
440
+ // section simultaneously. AsyncLocalStorage scopes the "held" set to the current
441
+ // async context, so a call from a different async context no longer bypasses and
442
+ // properly serializes against the on-disk lock. True nested calls in the SAME
443
+ // async context (e.g. handleResume -> executeTeamRun -> executeTeamRunCore) still
444
+ // bypass via lockCtx.getStore(), avoiding the deadlock the bypass was designed for.
445
+ const lockCtx = new AsyncLocalStorage<Set<string>>();
446
+ // Round 29: parallel map for withFileLockSync re-entrance. See the comment
447
+ // at the top of withFileLockSync for the full deadlock mechanism.
448
+ const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
449
+
382
450
  export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
383
451
  const filePath = lockPath(manifest);
384
452
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
385
- const existingToken = runLockHeldByUs.get(filePath);
386
- if (existingToken) {
387
- // Re-entrant: already hold this lock, just run the callback.
453
+ // H-1: re-entrance check is scoped to the current async context, not process-global.
454
+ // A call from a DIFFERENT async context (e.g. a child-stdout event handler that
455
+ // runs while an async holder is awaiting) sees no held set and properly serializes.
456
+ if (lockCtx.getStore()?.has(filePath)) {
457
+ // Re-entrant within the same async context — already hold this lock.
388
458
  return fn();
389
459
  }
390
460
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
391
461
  const token = acquireLockWithRetry(filePath, staleMs, "run");
392
- runLockHeldByUs.set(filePath, token);
393
- try {
394
- return fn();
395
- } finally {
396
- runLockHeldByUs.delete(filePath);
397
- releaseLock(filePath, token);
398
- }
462
+ const prevHeld = lockCtx.getStore() ?? new Set<string>();
463
+ const newHeld = new Set(prevHeld);
464
+ newHeld.add(filePath);
465
+ // Enter a new async context (or extend the current one) carrying the held set.
466
+ // On exit (sync return OR async resume after an awaited child), the context is
467
+ // restored — so the held set only contains locks acquired within fn()'s subtree.
468
+ return lockCtx.run(newHeld, () => {
469
+ try {
470
+ return fn();
471
+ } finally {
472
+ // FIX (CI flake): releaseLock uses token matching to prevent the
473
+ // "losing contender wipes winner's lock" race in multi-process scenarios.
474
+ // But within withRunLockSync/withRunLock (same process), the new acquire
475
+ // uses a FRESH random token — the previous stored token doesn't match the
476
+ // current token — and the lock file is never removed. Repeat acquisitions
477
+ // then keep failing with EEXIST because the file lingers.
478
+ // Since withRunLock* is always single-process, unconditionally delete
479
+ // (symlink guard is still applied for safety).
480
+ releaseOwnLock(filePath, token);
481
+ }
482
+ });
399
483
  }
400
484
 
401
- // Track re-entrant lock acquisitions per lock file path. When a lock is
402
- // already held by this call stack (handleResume -> executeTeamRun ->
403
- // executeTeamRunCore), we skip re-acquisition to avoid deadlock.
404
- const runLockHeldByUs = new Map<string, string>(); // filePath -> token
405
- // Round 29: parallel map for withFileLockSync re-entrance. See the comment
406
- // at the top of withFileLockSync for the full deadlock mechanism.
407
- const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
408
-
409
485
  export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
410
486
  const filePath = lockPath(manifest);
411
487
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
412
- const existingToken = runLockHeldByUs.get(filePath);
413
- if (existingToken) {
414
- // Re-entrant: already hold this lock, just run the callback.
488
+ if (lockCtx.getStore()?.has(filePath)) {
489
+ // Re-entrant within the same async context.
415
490
  return await fn();
416
491
  }
417
492
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
418
493
  const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
419
- runLockHeldByUs.set(filePath, token);
420
- try {
421
- return await fn();
422
- } finally {
423
- runLockHeldByUs.delete(filePath);
424
- releaseLock(filePath, token);
425
- }
494
+ const prevHeld = lockCtx.getStore() ?? new Set<string>();
495
+ const newHeld = new Set(prevHeld);
496
+ newHeld.add(filePath);
497
+ // AsyncLocalStorage propagates across await boundaries, so any code that
498
+ // resumes after an await inside fn() (including microtasks, timers, and
499
+ // awaited child Pi results) still sees newHeld.
500
+ return await lockCtx.run(newHeld, async () => {
501
+ try {
502
+ return await fn();
503
+ } finally {
504
+ // FIX (CI flake): see withRunLockSync above — use releaseOwnLock for
505
+ // unconditional deletion of the lock file we created (same process).
506
+ releaseOwnLock(filePath, token);
507
+ }
508
+ });
426
509
  }
@@ -3,7 +3,6 @@ import * as path from "node:path";
3
3
  import { DEFAULT_MAILBOX } from "../config/defaults.ts";
4
4
  import { logInternalError } from "../utils/internal-error.ts";
5
5
  import { redactSecrets } from "../utils/redaction.ts";
6
- import { resolveRealContainedPath } from "../utils/safe-paths.ts";
7
6
  import { atomicWriteFile } from "./atomic-write.ts";
8
7
  import { withEventLogLockSync } from "./event-log.ts";
9
8
  import { withFileLockSync } from "./locks.ts";
@@ -184,13 +183,13 @@ function ensureRunMailbox(manifest: TeamRunManifest): void {
184
183
  // Ensure parent dir exists (may have been lost due to race or
185
184
  // Windows path normalization mismatch)
186
185
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
187
- fs.writeFileSync(filePath, "", "utf-8");
186
+ atomicWriteFile(filePath, "");
188
187
  }
189
188
  }
190
189
  const delivery = deliveryFile(manifest, true);
191
190
  if (!fs.existsSync(delivery)) {
192
191
  fs.mkdirSync(path.dirname(delivery), { recursive: true });
193
- fs.writeFileSync(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf-8");
192
+ atomicWriteFile(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`);
194
193
  }
195
194
  }
196
195
 
@@ -312,7 +311,7 @@ function rotateMailboxFileIfNeeded(filePath: string, thresholdBytes = MAILBOX_AR
312
311
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
313
312
  const archivePath = `${filePath}.${ts}.archive.jsonl`;
314
313
  fs.renameSync(filePath, archivePath);
315
- fs.writeFileSync(filePath, "", "utf-8");
314
+ atomicWriteFile(filePath, "");
316
315
  // FIX: Prune old archives so total per-direction count stays bounded.
317
316
  pruneOldMailboxArchives(filePath);
318
317
  return true;
@@ -1,16 +1,4 @@
1
- /**
2
- * Observation capture and compression system.
3
- *
4
- * Pattern origin: claude-mem — captures tool usage across sessions,
5
- * compresses via AI, injects into future sessions.
6
- *
7
- * This module provides the observation store and compression logic.
8
- * Actual capture hooks into the lifecycle events (Pattern 12).
9
- */
10
-
11
- import * as fs from "node:fs";
12
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
13
- import * as path from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
14
2
  import { logInternalError } from "../utils/internal-error.ts";
15
3
  import { atomicWriteJson } from "./atomic-write.ts";
16
4
 
@@ -2,7 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { projectCrewRoot } from "../utils/paths.ts";
5
- import { atomicWriteFile } from "./atomic-write.ts";
5
+ import { atomicWriteJson } from "./atomic-write.ts";
6
6
  import { withFileLockSync } from "./locks.ts";
7
7
  import type { TeamTaskState } from "./types.ts";
8
8
 
@@ -67,7 +67,7 @@ export function getCachedRun(cwd: string, cacheKey: string): CacheEntry | null {
67
67
  }
68
68
  const updatedIndex = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
69
69
  delete updatedIndex[cacheKey];
70
- atomicWriteFile(indexPath, JSON.stringify(updatedIndex));
70
+ atomicWriteJson(indexPath, updatedIndex);
71
71
  });
72
72
  return null;
73
73
  }
@@ -109,18 +109,18 @@ export function saveRunToCache(
109
109
  };
110
110
 
111
111
  const entryPath = path.join(dir, `${cacheKey}.json`);
112
- fs.writeFileSync(entryPath, JSON.stringify(entry), "utf-8");
112
+ // ST-4: atomic entry write (was raw writeFileSync — inverted priority vs the index).
113
+ atomicWriteJson(entryPath, entry);
113
114
 
114
- // Update index with atomic write: write to temp file then rename
115
+ // Update index under a file lock (prevents concurrent saveCache from racing on the
116
+ // RMW) and write atomically (unique temp + rename — fixes the fixed "index.json.tmp"
117
+ // name collision the old manual temp+rename had).
115
118
  const indexPath = path.join(dir, "index.json");
116
- const index: CacheIndex = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf-8")) : {};
117
-
118
- index[cacheKey] = entryPath;
119
-
120
- // Atomic write: write to temp file first, then rename
121
- const tempPath = path.join(dir, "index.json.tmp");
122
- fs.writeFileSync(tempPath, JSON.stringify(index), "utf-8");
123
- fs.renameSync(tempPath, indexPath);
119
+ withFileLockSync(indexPath, () => {
120
+ const index: CacheIndex = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf-8")) : {};
121
+ index[cacheKey] = entryPath;
122
+ atomicWriteJson(indexPath, index);
123
+ });
124
124
  }
125
125
 
126
126
  /**
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { projectCrewRoot } from "../utils/paths.ts";
4
4
  import { assertSafePathId } from "../utils/safe-paths.ts";
5
+ import { atomicWriteFile } from "./atomic-write.ts";
5
6
  import type { TeamRunManifest, TeamTaskState } from "./types.ts";
6
7
 
7
8
  export interface RunGraphNode {
@@ -138,7 +139,7 @@ export function saveRunGraph(graph: RunGraph, cwd: string): string {
138
139
  }
139
140
 
140
141
  const graphPath = path.join(graphsDir, `${graph.runId}.json`);
141
- fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2), "utf-8");
142
+ atomicWriteFile(graphPath, JSON.stringify(graph, null, 2));
142
143
 
143
144
  return graphPath;
144
145
  }
@@ -66,7 +66,7 @@ export function collectRunMetrics(cwd: string, runId: string): RunMetrics | unde
66
66
  // Duration: from run createdAt to updatedAt (manifest timestamps), or 0 if unavailable.
67
67
  const createdAt = new Date(manifest.createdAt).getTime();
68
68
  const updatedAt = new Date(manifest.updatedAt).getTime();
69
- const durationMs = isNaN(createdAt) || isNaN(updatedAt) ? 0 : Math.max(0, updatedAt - createdAt);
69
+ const durationMs = Number.isNaN(createdAt) || Number.isNaN(updatedAt) ? 0 : Math.max(0, updatedAt - createdAt);
70
70
 
71
71
  // Consistency score: proportion of tasks that completed successfully among all non-skipped tasks.
72
72
  const nonSkippedTasks = tasks.filter((t) => t.status !== "skipped");
@@ -10,6 +10,7 @@
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { logInternalError } from "../utils/internal-error.ts";
13
+ import { atomicWriteJson } from "./atomic-write.ts";
13
14
 
14
15
  import type { ScheduledTask, ScheduleStoreData } from "./types.ts";
15
16
 
@@ -112,7 +113,12 @@ export class ScheduleStore {
112
113
  private save(): void {
113
114
  try {
114
115
  fs.mkdirSync(path.dirname(this.path), { recursive: true });
115
- fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2), "utf-8");
116
+ // Atomic (temp + rename + fsync) — matches the class docstring's "atomic JSON
117
+ // persistence" claim. A raw writeFileSync could truncate schedules.json on
118
+ // crash and the constructor silently resets to {jobs:[]} on parse failure.
119
+ // NOTE: this class is currently not instantiated in production (scheduling
120
+ // persists via settings-store updateCrewSettings); kept consistent for safety.
121
+ atomicWriteJson(this.path, this.data);
116
122
  } catch (error) {
117
123
  // FIX (Round 21, L1): Use logInternalError for consistency with
118
124
  // the rest of the codebase. Previously console.warn may not be
@@ -14,7 +14,6 @@ import { unregisterActiveRun } from "./active-run-registry.ts";
14
14
  import { atomicWriteJson, atomicWriteJsonAsync, atomicWriteJsonCoalesced, flushPendingAtomicWrites, readJsonFile } from "./atomic-write.ts";
15
15
  import { canTransitionRunStatus } from "./contracts.ts";
16
16
  import { appendEvent } from "./event-log.ts";
17
- import { HealthStore } from "./health-store.ts";
18
17
  import { withRunLock, withRunLockSync } from "./locks.ts";
19
18
  import type { TeamRunManifest, TeamTaskState } from "./types.ts";
20
19
 
@@ -477,9 +476,16 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
477
476
  * (perf review 2026-07 F4) — now used by persistSingleTaskUpdate's
478
477
  * non-terminal checkpoint path; that caller calls flushPendingAtomicWrites()
479
478
  * before its read-modify-write load to defeat the stale-read window.
479
+ *
480
+ * ST-7: pass `skipCoalesce: true` for terminal task status transitions
481
+ * (completed/failed/cancelled/needs_attention/skipped) to bypass the
482
+ * 50ms coalesce window — a SIGKILL in that window would otherwise leave
483
+ * tasks.json stale (showing "running") while events.jsonl already shows
484
+ * the terminal event, causing false zombie detection / double-execution
485
+ * on crash recovery.
480
486
  */
481
487
  /** @internal */
482
- export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
488
+ export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[], skipCoalesce: boolean = false): void {
483
489
  // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
484
490
  invalidateRunCache(manifest.stateRoot);
485
491
  try {
@@ -487,7 +493,7 @@ export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTask
487
493
  } catch {
488
494
  return;
489
495
  }
490
- atomicWriteJsonCoalesced(manifest.tasksPath, tasks);
496
+ atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, undefined, skipCoalesce);
491
497
  }
492
498
 
493
499
  export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
@@ -142,7 +142,7 @@ export class TieredEvalRunner {
142
142
  if (config?.tierConfigs) {
143
143
  for (const [tierStr, tierConfig] of Object.entries(config.tierConfigs)) {
144
144
  const tier = Number(tierStr) as EvalTier;
145
- if (tierConfig && !isNaN(tier)) {
145
+ if (tierConfig && !Number.isNaN(tier)) {
146
146
  this.tierConfigs[tier] = {
147
147
  ...this.tierConfigs[tier],
148
148
  ...tierConfig,
@@ -137,6 +137,7 @@ function getWorker(): Worker {
137
137
  // Reject ALL pending requests — worker died.
138
138
  for (const [, entry] of pending) entry.reject(error);
139
139
  pending.clear();
140
+ worker = undefined;
140
141
  });
141
142
  worker.unref(); // don't keep event loop alive (unless tests request it)
142
143
  if (keepRefForTests) worker.ref();
@@ -9,6 +9,8 @@ export class DynamicCrewBorder {
9
9
  private readonly theme: CrewTheme;
10
10
  private readonly color?: (value: string) => string;
11
11
  private readonly char: string;
12
+ private cachedWidth = -1;
13
+ private cachedLine = "";
12
14
 
13
15
  constructor(theme: CrewTheme, options: DynamicCrewBorderOptions = {}) {
14
16
  this.theme = theme;
@@ -17,9 +19,17 @@ export class DynamicCrewBorder {
17
19
  }
18
20
 
19
21
  render(width: number): string[] {
20
- const line = this.char.repeat(Math.max(0, width));
21
- return [this.color ? this.color(line) : this.theme.fg("border", line)];
22
+ const w = Math.max(0, width);
23
+ if (w !== this.cachedWidth) {
24
+ const line = this.char.repeat(w);
25
+ this.cachedLine = this.color ? this.color(line) : this.theme.fg("border", line);
26
+ this.cachedWidth = w;
27
+ }
28
+ return [this.cachedLine];
22
29
  }
23
30
 
24
- invalidate(): void {}
31
+ invalidate(): void {
32
+ this.cachedWidth = -1;
33
+ this.cachedLine = "";
34
+ }
25
35
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { LiveAgentHandle } from "../runtime/live-agent-manager.ts";
8
8
  import { pad, truncate } from "../utils/visual.ts";
9
+ import { computeLiveDurationMs } from "./live-duration.ts";
9
10
  import { spinnerFrame } from "./spinner.ts";
10
11
  import { iconForStatus } from "./status-colors.ts";
11
12
  import type { CrewTheme } from "./theme-adapter.ts";
@@ -17,7 +18,6 @@ export class LiveConversationOverlay {
17
18
  private scrollOffset = 0;
18
19
  private autoScroll = true;
19
20
  private closed = false;
20
- private frame = 0;
21
21
  private pollTimer: ReturnType<typeof setInterval> | undefined;
22
22
  cachedLines: string[] = [];
23
23
  // H-4 fix (code-review 2026-06-23): cap the in-memory line buffer to avoid
@@ -53,10 +53,12 @@ export class LiveConversationOverlay {
53
53
  /* ignore */
54
54
  }
55
55
  }
56
- // Also poll for summary updates
56
+ // Also poll for summary updates. Skip when the user has scrolled up
57
+ // (autoScroll === false): the summary refresh also bumps scrollOffset
58
+ // to the tail, which would yank the viewport out from under them.
57
59
  this.pollTimer = setInterval(() => {
58
60
  if (this.closed) return;
59
- this.frame++;
61
+ if (!this.autoScroll) return;
60
62
  try {
61
63
  this.refreshSummary();
62
64
  } catch {
@@ -82,31 +84,9 @@ export class LiveConversationOverlay {
82
84
 
83
85
  private static readonly SUMMARY_PREFIX = "\u200B"; // zero-width space as summary sentinel
84
86
 
85
- private safeElapsedMs(act: typeof this.handle.activity): number {
86
- const rawStarted = act.startedAtMs || 0;
87
- const rawCompleted = act.completedAtMs || 0;
88
- const nowMs = Date.now();
89
- const nowSec = Math.floor(nowMs / 1000);
90
- // Simple fix: detect if value is Unix seconds and convert properly
91
- const toMs = (v: number): number => {
92
- if (v <= 0) return 0;
93
- // If 10 digits (or 9 with recent), treat as seconds
94
- if (v > 1000000000 && v < 10000000000) return v * 1000;
95
- // If 13 digits, treat as ms
96
- if (v > 100000000000 && v < 10000000000000) return v;
97
- // Fallback: use as-is
98
- return v;
99
- };
100
- const startedMs = toMs(rawStarted);
101
- const completedMs = rawCompleted > 0 ? toMs(rawCompleted) : 0;
102
- // Validate bounds
103
- const isValidStarted = startedMs > 0 && startedMs < nowMs + 60000 && startedMs > nowMs - 3155692600000;
104
- const isValidCompleted = completedMs === 0 || (completedMs > 0 && completedMs < nowMs + 60000);
105
- return (isValidCompleted ? completedMs : nowMs) - (isValidStarted ? startedMs : nowMs);
106
- }
107
87
  private refreshSummary(): void {
108
88
  const act = this.handle.activity;
109
- const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${act.turnCount} turns · ${act.toolUses} tools · ${(this.safeElapsedMs(act) / 1000).toFixed(1)}s]`;
89
+ const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${act.turnCount} turns · ${act.toolUses} tools · ${(computeLiveDurationMs(act) / 1000).toFixed(1)}s]`;
110
90
  const lastLine = this.cachedLines[this.cachedLines.length - 1];
111
91
  if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
112
92
  this.cachedLines[this.cachedLines.length - 1] = summary;
@@ -140,7 +120,7 @@ export class LiveConversationOverlay {
140
120
  : iconForStatus(this.handle.status);
141
121
  const name = this.handle.agent ?? this.handle.taskId;
142
122
  const act = this.handle.activity;
143
- const elapsed = `${(this.safeElapsedMs(act) / 1000).toFixed(1)}s`;
123
+ const elapsed = `${(computeLiveDurationMs(act) / 1000).toFixed(1)}s`;
144
124
  const headerParts: string[] = [];
145
125
  if (act.maxTurns != null) headerParts.push(`turn ${act.turnCount}/${act.maxTurns}`);
146
126
  else if (act.turnCount > 0) headerParts.push(`turn ${act.turnCount}`);
@@ -21,8 +21,8 @@ function toMs(v: number): number {
21
21
  if (v <= 0) return 0;
22
22
  // 1e9 < seconds < 1e10 → seconds, scale up
23
23
  if (v > 1_000_000_000 && v < 10_000_000_000) return v * 1000;
24
- // 1e11 < ms < 1e13 → already ms
25
- if (v > 100_000_000_000 && v < 10_000_000_000_000) return v;
24
+ // 1e10 < ms < 1e13 → already ms (>=10B seconds is 317+ years, implausible)
25
+ if (v > 10_000_000_000 && v < 10_000_000_000_000) return v;
26
26
  return v;
27
27
  }
28
28