pi-crew 0.9.41 → 0.9.42

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 (147) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/build-meta.json +7087 -6726
  3. package/dist/index.mjs +43043 -42426
  4. package/dist/index.mjs.map +4 -4
  5. package/docs/migration/atomic-write-v2-migration.md +2 -0
  6. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  7. package/package.json +4 -2
  8. package/scripts/README.md +6 -0
  9. package/scripts/check-all-skills.ts +3 -3
  10. package/scripts/dev-runner.mjs +106 -0
  11. package/scripts/postinstall.mjs +6 -2
  12. package/scripts/verify-skill.ts +2 -2
  13. package/src/config/config.ts +19 -0
  14. package/src/config/drift-detector.ts +97 -15
  15. package/src/config/resilient-parser.ts +4 -4
  16. package/src/extension/crew-cleanup.ts +17 -3
  17. package/src/extension/crew-vibes/index.ts +1 -1
  18. package/src/extension/cross-extension-rpc.ts +18 -5
  19. package/src/extension/management.ts +6 -5
  20. package/src/extension/register.ts +58 -1573
  21. package/src/extension/registration/command-registration.ts +57 -0
  22. package/src/extension/registration/commands.ts +2 -1
  23. package/src/extension/registration/context-builder.ts +131 -0
  24. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  25. package/src/extension/registration/foreground-run-controller.ts +210 -0
  26. package/src/extension/registration/hook-registration.ts +119 -0
  27. package/src/extension/registration/lazy-configurers.ts +124 -0
  28. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  29. package/src/extension/registration/observability.ts +33 -4
  30. package/src/extension/registration/registration-types.ts +149 -0
  31. package/src/extension/registration/runtime-cleanup.ts +230 -0
  32. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  33. package/src/extension/registration/tool-registration.ts +50 -0
  34. package/src/extension/registration/wire-cross-extension.ts +37 -0
  35. package/src/extension/rpc-hmac.ts +21 -1
  36. package/src/extension/run-import.ts +3 -3
  37. package/src/extension/team-tool/auto-summarize.ts +4 -4
  38. package/src/extension/team-tool/cancel.ts +5 -5
  39. package/src/extension/team-tool/chain-executor.ts +5 -4
  40. package/src/extension/team-tool/context.ts +11 -1
  41. package/src/extension/team-tool/doctor.ts +2 -1
  42. package/src/extension/team-tool/goal-wrap.ts +2 -2
  43. package/src/extension/team-tool/goal.ts +1 -1
  44. package/src/extension/team-tool/handle-schedule.ts +25 -36
  45. package/src/extension/team-tool/orchestrate.ts +0 -1
  46. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  47. package/src/extension/team-tool/run.ts +220 -214
  48. package/src/extension/team-tool/workflow-manage.ts +1 -1
  49. package/src/extension/team-tool.ts +64 -16
  50. package/src/hooks/registry.ts +7 -2
  51. package/src/observability/correlation.ts +4 -3
  52. package/src/observability/event-bus.ts +10 -1
  53. package/src/observability/event-to-metric.ts +8 -7
  54. package/src/observability/exporters/adapter.ts +6 -9
  55. package/src/observability/exporters/otlp-exporter.ts +81 -0
  56. package/src/observability/metric-retention.ts +4 -0
  57. package/src/observability/metric-sink.ts +14 -4
  58. package/src/runtime/adaptive-plan.ts +1 -1
  59. package/src/runtime/async-runner.ts +13 -4
  60. package/src/runtime/background-runner.ts +32 -23
  61. package/src/runtime/capability-inventory.ts +1 -1
  62. package/src/runtime/chain-runner.ts +5 -5
  63. package/src/runtime/checkpoint.ts +6 -16
  64. package/src/runtime/child-pi.ts +22 -2
  65. package/src/runtime/coalesce-tasks.ts +1 -1
  66. package/src/runtime/crash-recovery.ts +63 -47
  67. package/src/runtime/delivery-coordinator.ts +1 -1
  68. package/src/runtime/deterministic-ast.ts +2 -2
  69. package/src/runtime/diagnostic-export.ts +2 -1
  70. package/src/runtime/foreground-control.ts +3 -2
  71. package/src/runtime/goal-loop-runner.ts +6 -2
  72. package/src/runtime/goal-state-store.ts +1 -1
  73. package/src/runtime/handoff-manager.ts +0 -2
  74. package/src/runtime/intercom-bridge.ts +0 -2
  75. package/src/runtime/live-agent-manager.ts +12 -5
  76. package/src/runtime/live-extension-bridge.ts +0 -2
  77. package/src/runtime/live-session-runtime.ts +65 -57
  78. package/src/runtime/manifest-cache.ts +59 -0
  79. package/src/runtime/mcp-proxy.ts +1 -1
  80. package/src/runtime/orphan-worker-registry.ts +3 -3
  81. package/src/runtime/output-validator.ts +1 -1
  82. package/src/runtime/path-overlap.ts +1 -1
  83. package/src/runtime/pi-args.ts +3 -3
  84. package/src/runtime/pi-json-output.ts +2 -2
  85. package/src/runtime/pipeline-runner.ts +1 -4
  86. package/src/runtime/post-checks.ts +0 -1
  87. package/src/runtime/retry-runner.ts +1 -1
  88. package/src/runtime/run-coalesced-task-group.ts +0 -13
  89. package/src/runtime/scheduler.ts +2 -0
  90. package/src/runtime/session-resources.ts +1 -1
  91. package/src/runtime/settings-store.ts +31 -1
  92. package/src/runtime/skill-effectiveness.ts +2 -2
  93. package/src/runtime/stale-reconciler.ts +23 -53
  94. package/src/runtime/subagent-manager.ts +19 -3
  95. package/src/runtime/task-output-context.ts +2 -1
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -1
  97. package/src/runtime/task-runner/run-projection.ts +3 -1
  98. package/src/runtime/task-runner/state-helpers.ts +13 -1
  99. package/src/runtime/task-runner.ts +31 -6
  100. package/src/runtime/team-runner.ts +3 -2
  101. package/src/runtime/verification-gates.ts +54 -5
  102. package/src/schema/config-schema.ts +19 -1
  103. package/src/schema/team-tool-schema.ts +2 -0
  104. package/src/skills/discover-skills.ts +25 -15
  105. package/src/state/active-run-registry.ts +0 -1
  106. package/src/state/atomic-write.ts +37 -11
  107. package/src/state/blob-store.ts +3 -9
  108. package/src/state/contracts.ts +5 -1
  109. package/src/state/crew-init.ts +3 -2
  110. package/src/state/decision-ledger.ts +2 -2
  111. package/src/state/event-log-rotation.ts +1 -1
  112. package/src/state/event-log.ts +3 -3
  113. package/src/state/gitignore-manager.ts +2 -1
  114. package/src/state/health-store.ts +5 -2
  115. package/src/state/instinct-store.ts +11 -2
  116. package/src/state/mailbox.ts +3 -4
  117. package/src/state/observation-store.ts +1 -13
  118. package/src/state/run-cache.ts +12 -12
  119. package/src/state/run-graph.ts +2 -1
  120. package/src/state/run-metrics.ts +1 -1
  121. package/src/state/schedule.ts +7 -1
  122. package/src/state/state-store.ts +9 -3
  123. package/src/state/tiered-eval.ts +1 -1
  124. package/src/ui/dynamic-border.ts +13 -3
  125. package/src/ui/live-conversation-overlay.ts +7 -27
  126. package/src/ui/live-duration.ts +2 -2
  127. package/src/ui/live-run-sidebar.ts +56 -32
  128. package/src/ui/mascot.ts +19 -13
  129. package/src/ui/powerbar-publisher.ts +244 -178
  130. package/src/ui/render-coalescer.ts +44 -3
  131. package/src/ui/render-diff.ts +2 -2
  132. package/src/ui/run-dashboard.ts +122 -21
  133. package/src/ui/run-event-bus.ts +66 -0
  134. package/src/ui/run-snapshot-cache.ts +84 -81
  135. package/src/ui/settings-overlay.ts +3 -3
  136. package/src/ui/status-colors.ts +24 -0
  137. package/src/ui/tool-progress-formatter.ts +1 -1
  138. package/src/ui/tool-render.ts +3 -3
  139. package/src/ui/transcript-cache.ts +5 -10
  140. package/src/ui/transcript-entries.ts +68 -72
  141. package/src/ui/transcript-viewer.ts +54 -14
  142. package/src/ui/widget/index.ts +26 -15
  143. package/src/ui/widget/widget-model.ts +18 -9
  144. package/src/ui/widget/widget-renderer.ts +2 -5
  145. package/src/utils/fingerprint.ts +1 -1
  146. package/src/utils/project-detector.ts +1 -1
  147. package/src/state/atomic-write-v2.ts +0 -108
@@ -315,8 +315,13 @@ function isRetryableLinkError(error: unknown): boolean {
315
315
  * It atomically creates a hard link to the source. We then unlink the source.
316
316
  * This prevents TOCTOU attacks where an attacker plants a symlink at the
317
317
  * destination between the check and the rename operation.
318
+ *
319
+ * ST-8: this is the canonical symlink-safe rename helper used by both
320
+ * `atomicWriteFile` (sync) and `atomicWriteFileAsync` (async) paths. The
321
+ * helper is exported so any future caller can share the same symlink-safe
322
+ * rename semantics; keep this signature stable.
318
323
  */
319
- function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): void {
324
+ export function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): void {
320
325
  let lastError: unknown;
321
326
  for (let attempt = 0; attempt <= retries; attempt++) {
322
327
  try {
@@ -371,7 +376,12 @@ function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): vo
371
376
  throw lastError;
372
377
  }
373
378
 
374
- async function renameWithLinkAsync(tempPath: string, filePath: string, retries = 8): Promise<void> {
379
+ /**
380
+ * ST-8: async twin of `renameWithLinkSync`. Exported so any future caller
381
+ * can share the same symlink-safe rename semantics as the primary
382
+ * `atomicWriteFileAsync` path.
383
+ */
384
+ export async function renameWithLinkAsync(tempPath: string, filePath: string, retries = 8): Promise<void> {
375
385
  let lastError: unknown;
376
386
  for (let attempt = 0; attempt <= retries; attempt++) {
377
387
  try {
@@ -496,21 +506,21 @@ export type WriteDurability = "full" | "best-effort";
496
506
  /** Options accepted by atomicWriteFile (forward-compatible string | object form). */
497
507
  export type AtomicWriteOptions =
498
508
  | string // legacy: expectedHash
499
- | { expectedHash?: string; durability?: WriteDurability };
509
+ | { expectedHash?: string; durability?: WriteDurability; mode?: number };
500
510
 
501
- function normalizeOptions(arg: unknown): { expectedHash?: string; durability: WriteDurability } {
502
- if (typeof arg === "string") return { expectedHash: arg, durability: "full" };
511
+ function normalizeOptions(arg: unknown): { expectedHash?: string; durability: WriteDurability; mode?: number } {
512
+ if (typeof arg === "string") return { expectedHash: arg, durability: "full", mode: undefined };
503
513
  if (arg && typeof arg === "object") {
504
- const o = arg as { expectedHash?: string; durability?: WriteDurability };
514
+ const o = arg as { expectedHash?: string; durability?: WriteDurability; mode?: number };
505
515
  const durability: WriteDurability = o.durability === "best-effort" ? "best-effort" : "full";
506
- return { expectedHash: o.expectedHash, durability };
516
+ return { expectedHash: o.expectedHash, durability, mode: o.mode };
507
517
  }
508
- return { durability: "full" };
518
+ return { durability: "full", mode: undefined };
509
519
  }
510
520
 
511
521
  export function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): void {
512
522
  cancelPendingCoalescedWrite(filePath);
513
- const { durability } = normalizeOptions(options);
523
+ const { durability, mode } = normalizeOptions(options);
514
524
  if (!isSymlinkSafeDirCached(filePath))
515
525
  throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
516
526
  // On Windows the parent directory may be referenced via a short-name alias
@@ -556,7 +566,7 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
556
566
  const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
557
567
  let fd: number | undefined;
558
568
  try {
559
- fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
569
+ fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, mode ?? 0o600);
560
570
  // Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
561
571
  const openedStat = fs.fstatSync(fd);
562
572
  if (!openedStat.isFile()) {
@@ -747,6 +757,14 @@ let flushInProgress = 0;
747
757
  * Buffer a JSON write and flush it after `coalesceMs` ms (default 50).
748
758
  * Multiple writes to the same path within the window collapse to one disk write.
749
759
  *
760
+ * ST-7 escape hatch: pass `skipCoalesce: true` to bypass the buffer entirely
761
+ * and write synchronously via `atomicWriteJson`. Use for terminal task status
762
+ * transitions where a SIGKILL during the 50ms coalesce window must NOT lose
763
+ * the terminal status update — otherwise crash recovery would see "running"
764
+ * in tasks.json while events.jsonl already shows "completed", causing
765
+ * double-execution / zombie detection. Defaults to `false` (buffered write)
766
+ * so all existing callers preserve their behavior.
767
+ *
750
768
  * @see The coalescing caveat: a `readJsonFile` call between buffer and flush
751
769
  * sees the previous on-disk content. Callers needing read-after-write
752
770
  * must call `flushPendingAtomicWrites()` first or use `atomicWriteJson`.
@@ -754,9 +772,17 @@ let flushInProgress = 0;
754
772
  export function atomicWriteJsonCoalesced<T>(
755
773
  filePath: string,
756
774
  value: T,
757
- coalesceMs = DEFAULT_ATOMIC_COALESCE_MS,
775
+ coalesceMs: number = DEFAULT_ATOMIC_COALESCE_MS,
758
776
  options?: AtomicWriteOptions,
777
+ skipCoalesce: boolean = false,
759
778
  ): void {
779
+ // ST-7: bypass coalescing for callers that need durable synchronous
780
+ // semantics. atomicWriteJson goes through atomicWriteFile which uses
781
+ // renameWithLinkSync + fsync — fully durable, no SIGKILL window.
782
+ if (skipCoalesce) {
783
+ atomicWriteJson(filePath, value, options);
784
+ return;
785
+ }
760
786
  const content = `${JSON.stringify(value, null, 2)}\n`;
761
787
  const previous = pendingAtomicWrites.get(filePath);
762
788
  if (previous) clearTimeout(previous.timer);
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
5
- import { sleepSync } from "../utils/sleep.ts";
6
5
  import { atomicWriteFile, renameWithRetry } from "./atomic-write.ts";
7
6
  import { withFileLockSync } from "./locks.ts";
8
7
 
@@ -152,14 +151,9 @@ export function writeBlob(
152
151
  // FIX: Write blob content FIRST, then metadata. If blob write fails, no orphan metadata.
153
152
  // Previous order was metadata first, then blob - causing orphan metadata on blob failure.
154
153
  let blobContentWritten = false;
155
- try {
156
- // Write blob content first (immutable, content-addressed)
157
- atomicWriteBuffer(blobPath, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf-8"));
158
- blobContentWritten = true;
159
- } catch (error) {
160
- // Blob write failed - no metadata to clean up since we haven't written it yet
161
- throw error;
162
- }
154
+ // Write blob content first (immutable, content-addressed)
155
+ atomicWriteBuffer(blobPath, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf-8"));
156
+ blobContentWritten = true;
163
157
 
164
158
  // Metadata only after blob content is successfully written
165
159
  // Issue 3 fix: Use two-phase commit for metadata - write to temp file first,
@@ -44,7 +44,7 @@ export const TEAM_TASK_STATUS_TRANSITIONS: Readonly<Record<TeamTaskStatus, reado
44
44
  };
45
45
 
46
46
  /** @internal */
47
- const TEAM_EVENT_TYPES = [
47
+ export const TEAM_EVENT_TYPES = [
48
48
  "run.created",
49
49
  "run.queued",
50
50
  "run.planning",
@@ -56,6 +56,10 @@ const TEAM_EVENT_TYPES = [
56
56
  "task.created",
57
57
  "task.started",
58
58
  "task.progress",
59
+ "hook.pre_step_started",
60
+ "hook.pre_step_completed",
61
+ "hook.pre_step_failed",
62
+ "hook.pre_step_optional_failed",
59
63
  "task.blocked",
60
64
  "task.green",
61
65
  "task.red",
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
+ import { atomicWriteFile } from "./atomic-write.ts";
20
21
  import { updateGitignore } from "./gitignore-manager.ts";
21
22
 
22
23
  // Re-export updateGitignore for backwards compatibility with tests.
@@ -238,12 +239,12 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
238
239
 
239
240
  for (const placeholder of placeholders) {
240
241
  if (!fs.existsSync(placeholder)) {
241
- fs.writeFileSync(placeholder, "", "utf-8");
242
+ atomicWriteFile(placeholder, "");
242
243
  }
243
244
  }
244
245
 
245
246
  // 3. Write README.md (always overwrite to keep it current)
246
- fs.writeFileSync(safeJoin(crewRoot, "README.md"), buildCrewReadme(), "utf-8");
247
+ atomicWriteFile(safeJoin(crewRoot, "README.md"), buildCrewReadme());
247
248
 
248
249
  // 4. Update .gitignore at project root
249
250
  const repoRoot = findProjectRoot(cwd);
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname, join } from "path";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import { projectCrewRoot } from "../utils/paths.ts";
4
4
  import { assertSafePathId } from "../utils/safe-paths.ts";
5
5
  import { atomicWriteFile } from "./atomic-write.ts";
@@ -220,7 +220,7 @@ export function rotateEventLogUnlocked(eventsPath: string): boolean {
220
220
  // copyFileSync + writeFileSync("") ensures eventsPath ALWAYS exists
221
221
  // (no missing-file window for concurrent readers).
222
222
  fs.copyFileSync(eventsPath, archivePath);
223
- fs.writeFileSync(eventsPath, "", "utf-8");
223
+ atomicWriteFile(eventsPath, "");
224
224
  return true;
225
225
  } catch (error) {
226
226
  logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);
@@ -93,7 +93,7 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
93
93
  const pidFile = path.join(lockDir, "pid");
94
94
  const start = Date.now();
95
95
  // SECURITY (HIGH #2 fix): Reduced from 120s to 5s to prevent blocking the
96
- // event loop indefinitely. 500 retries × 10ms = 5s max. After timeout, we
96
+ // event loop indefinitely. ~100 retries × 50ms ≈ 5s max. After timeout, we
97
97
  // throw a clear error instead of blocking forever. This ensures AbortSignal
98
98
  // handlers, SIGTERM, and graceful shutdown can fire within seconds.
99
99
  const timeout = options?.timeoutMs ?? 5000;
@@ -109,7 +109,7 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
109
109
  // (reduced from 120s) is appropriate.
110
110
  fs.mkdirSync(lockDir);
111
111
  try {
112
- fs.writeFileSync(pidFile, String(process.pid), "utf-8");
112
+ atomicWriteFile(pidFile, String(process.pid));
113
113
  } catch {
114
114
  /* best-effort */
115
115
  }
@@ -162,7 +162,7 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
162
162
  } catch {
163
163
  /* no pid file — mtime check above already handles it */
164
164
  }
165
- sleepSync(10);
165
+ sleepSync(50);
166
166
  }
167
167
  }
168
168
  try {
@@ -3,6 +3,7 @@
3
3
  * Only adds entries if not already present; preserves existing content.
4
4
  */
5
5
  import * as fs from "node:fs";
6
+ import { atomicWriteFile } from "./atomic-write.ts";
6
7
 
7
8
  /**
8
9
  * Entries to add to .gitignore for .crew directory management.
@@ -25,7 +26,7 @@ const CREW_GITIGNORE_ENTRIES = [
25
26
  */
26
27
  export async function updateGitignore(gitignorePath: string): Promise<void> {
27
28
  if (!fs.existsSync(gitignorePath)) {
28
- fs.writeFileSync(gitignorePath, CREW_GITIGNORE_ENTRIES.join("\n") + "\n", "utf-8");
29
+ atomicWriteFile(gitignorePath, CREW_GITIGNORE_ENTRIES.join("\n") + "\n");
29
30
  return;
30
31
  }
31
32
 
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ManifestSummary, RunHealth } from "../runtime/task-health.ts";
3
+ import type { ManifestSummary } from "../runtime/task-health.ts";
4
4
  import { computeRunHealth } from "../runtime/task-health.ts";
5
+ import { atomicWriteFile } from "./atomic-write.ts";
5
6
 
6
7
  // Relative to the crew root (`<cwd>/.crew`). BUG A fix (pts/2 hang
7
8
  // investigation 2026-06-16): this was `.crew/state/health`, which double-joined
@@ -41,7 +42,9 @@ export class HealthStore {
41
42
  const dir = this.healthDir();
42
43
  fs.mkdirSync(dir, { recursive: true });
43
44
  const file = path.join(dir, `${manifest.runId}.json`);
44
- fs.writeFileSync(file, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
45
+ // ST-2: atomic (temp + rename + fsync) — a crash mid-writeFileSync would truncate
46
+ // the snapshot and loadAllSnapshots() silently skips unparseable files.
47
+ atomicWriteFile(file, JSON.stringify(snapshot, null, 2) + "\n");
45
48
  return snapshot;
46
49
  }
47
50
 
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { assertSafePathId } from "../utils/safe-paths.ts";
5
+ import { atomicWriteFile } from "./atomic-write.ts";
5
6
 
6
7
  /**
7
8
  * Represents a learned instinct that guides agent behavior.
@@ -87,7 +88,13 @@ export class InstinctStore {
87
88
  private appendInstinctToFile(filePath: string, instinct: Instinct): void {
88
89
  const dir = path.dirname(filePath);
89
90
  this.ensureDir(dir);
90
- fs.appendFileSync(filePath, `${JSON.stringify(instinct)}\n`, "utf-8");
91
+ // ST-3: read existing + append + atomic rewrite. appendFileSync has no fsync and
92
+ // can interleave bytes on concurrent writes. Callers should wrap this in
93
+ // withFileLockSync(filePath) to serialize concurrent RMW on the same file.
94
+ const existing = fs.existsSync(filePath) ? this.readInstinctsFromFile(filePath) : [];
95
+ existing.push(instinct);
96
+ const content = existing.map((i) => JSON.stringify(i)).join("\n") + "\n";
97
+ atomicWriteFile(filePath, content);
91
98
  }
92
99
 
93
100
  /**
@@ -97,7 +104,9 @@ export class InstinctStore {
97
104
  const dir = path.dirname(filePath);
98
105
  this.ensureDir(dir);
99
106
  const content = instincts.map((i) => JSON.stringify(i)).join("\n") + "\n";
100
- fs.writeFileSync(filePath, content, "utf-8");
107
+ // ST-3: atomic write (temp + rename + fsync) — crash mid-writeFileSync truncates
108
+ // the JSONL and readInstinctsFromFile silently returns [] (all instincts lost).
109
+ atomicWriteFile(filePath, content);
101
110
  }
102
111
 
103
112
  /**
@@ -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,
@@ -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