pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -267,7 +267,7 @@ function isTargetNotSymlink(filePath: string): boolean {
267
267
  * entry is correctly reusable for every file written into that dir (no
268
268
  * cross-file cache poisoning).
269
269
  */
270
- function isSymlinkSafeDirCached(filePath: string): boolean {
270
+ export function isSymlinkSafeDirCached(filePath: string): boolean {
271
271
  const now = Date.now();
272
272
  const dir = path.dirname(filePath);
273
273
  // Always re-check the target file (uncached) before trusting the dir verdict.
@@ -549,6 +549,91 @@ function normalizeOptions(arg: unknown): { expectedHash?: string; durability: Wr
549
549
  return { durability: "full", mode: undefined, compact: undefined };
550
550
  }
551
551
 
552
+ // PERF (2026-08-24): every atomic write ran mkdirSync(recursive) on a parent
553
+ // that exists for the lifetime of a run. Memoize known-existing dirs; on the
554
+ // ENOENT retry at temp-open (memoized dir deleted underneath us) BOTH the dir
555
+ // memo AND the symlink-safety caches for that dir are dropped (forgetDir +
556
+ // invalidateSymlinkSafeCache) so a deleted-then-recreated tree re-runs the
557
+ // full symlink walk on the next write — without the invalidation the stale
558
+ // dir verdict would be trusted for up to the 10s symlinkSafeCache TTL.
559
+ const knownDirs = new Set<string>();
560
+ const KNOWN_DIRS_MAX = 512;
561
+ export function ensureDirSync(dirPath: string): void {
562
+ if (knownDirs.has(dirPath)) return;
563
+ fs.mkdirSync(dirPath, { recursive: true });
564
+ if (knownDirs.size >= KNOWN_DIRS_MAX) {
565
+ const oldest = knownDirs.keys().next().value;
566
+ if (oldest !== undefined) knownDirs.delete(oldest);
567
+ }
568
+ knownDirs.add(dirPath);
569
+ }
570
+ function forgetDir(dirPath: string): void {
571
+ knownDirs.delete(dirPath);
572
+ }
573
+
574
+ // T8 (perf round 2, 2026-08-25): grouped parent-dir fsync for coalesced drains.
575
+ //
576
+ // A full-durability atomic write fsyncs (1) the data file and (2) the parent
577
+ // directory after the rename. When a coalesced DRAIN
578
+ // (`flushPendingAtomicWrites()` with no argument) serially flushes N pending
579
+ // files, the per-file parent-dir fsync is redundant: rename(2) is atomic and
580
+ // visible to readers the instant it happens (independent of the dir fsync),
581
+ // and ONE fsync of the parent dir after ALL renames makes every rename in
582
+ // that dir crash-durable in a single journal commit. Measured on this repo's
583
+ // state burst (4 files, one dir): 59.4ms serial per-file dir-fsync vs 22.9ms
584
+ // with one shared trailing dir-fsync (−61%), full durability preserved. Files
585
+ // in distinct dirs still group per-dir.
586
+ //
587
+ // Ordering (R16-B1): every rename completes BEFORE the trailing dir-fsync —
588
+ // guaranteed because the drain loop is serial and synchronous and the
589
+ // trailing fsync only runs after the loop.
590
+ //
591
+ // Scoping: `dirFsyncDeferralDepth` is raised ONLY around the global-drain
592
+ // loop, so the deferral never leaks to other callers — direct `atomicWriteJson`
593
+ // / `atomicWriteFile` calls, the terminal `skipCoalesce` path, scoped
594
+ // `flushPendingAtomicWrites(filePath)` flushes, and coalesce-timer
595
+ // `flushOnePendingAtomicWrite` callbacks all run at depth 0 and keep their
596
+ // immediate dir-fsync. `pendingDirFsyncs` is drained from the drain's
597
+ // `finally` so a mid-drain throw still fsyncs the dirs of files that were
598
+ // already renamed. Nested drains are impossible via the existing
599
+ // `flushInProgress` guard. There is no async drain counterpart today; the
600
+ // async path therefore NEVER defers (see its comment at the dir-fsync site).
601
+ const pendingDirFsyncs = new Set<string>();
602
+ let dirFsyncDeferralDepth = 0;
603
+
604
+ /** Immediate (ungrouped) parent-dir fsync — the pre-T8 inline behavior. */
605
+ function fsyncParentDirImmediate(filePath: string): void {
606
+ try {
607
+ const dirFd = fs.openSync(path.dirname(filePath), "r");
608
+ fs.fsyncSync(dirFd);
609
+ fs.closeSync(dirFd);
610
+ } catch {
611
+ /* best-effort — not all filesystems support directory fsync */
612
+ }
613
+ }
614
+
615
+ /** Flush the dirs accumulated by a grouped drain: ONE open+fsync+close per
616
+ * DISTINCT dir. Called from the drain's `finally` so it fires even when the
617
+ * serial loop threw mid-drain — files renamed before the throw must still
618
+ * become crash-durable. Best-effort per dir, mirroring the inline path.
619
+ * The set is snapshotted+cleared up front so a throwing dir cannot skip the
620
+ * remaining dirs or leak entries into the next drain. */
621
+ function fsyncPendingParentDirs(): void {
622
+ if (pendingDirFsyncs.size === 0) return;
623
+ const dirs = [...pendingDirFsyncs];
624
+ pendingDirFsyncs.clear();
625
+ if (process.platform === "win32") return;
626
+ for (const dir of dirs) {
627
+ try {
628
+ const dirFd = fs.openSync(dir, "r");
629
+ fs.fsyncSync(dirFd);
630
+ fs.closeSync(dirFd);
631
+ } catch {
632
+ /* best-effort — not all filesystems support directory fsync */
633
+ }
634
+ }
635
+ }
636
+
552
637
  export function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): void {
553
638
  cancelPendingCoalescedWrite(filePath);
554
639
  const { durability, mode } = normalizeOptions(options);
@@ -581,7 +666,9 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
581
666
  };
582
667
  const dirPath = path.dirname(filePath);
583
668
  try {
584
- fs.mkdirSync(dirPath, { recursive: true });
669
+ // PERF (2026-08-24): memoized — on a memo hit this cannot throw, so the
670
+ // EPERM fallback below only ever runs when mkdir actually executed.
671
+ ensureDirSync(dirPath);
585
672
  } catch (error) {
586
673
  if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
587
674
  // mkdir hit a short/long-name alias wall — retry with the canonical
@@ -604,7 +691,24 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
604
691
  // removes a leftover instead of being skipped by the stale `fd` guard.
605
692
  let tempNeedsCleanup = false;
606
693
  try {
607
- fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, mode ?? 0o600);
694
+ // PERF (2026-08-24): the memoized parent may have been deleted after
695
+ // memoization — openSync then fails ENOENT. Forget the memo, re-create
696
+ // the dir, and retry the open exactly once; a second failure re-throws
697
+ // (through the outer finally, which has nothing to clean up yet since
698
+ // the temp never existed).
699
+ try {
700
+ fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, mode ?? 0o600);
701
+ } catch (openError) {
702
+ if ((openError as NodeJS.ErrnoException).code !== "ENOENT") throw openError;
703
+ forgetDir(dirPath);
704
+ // Parity hardening: drop the symlink-safety verdict for this dir too —
705
+ // the memoized dir may have been deleted and RECREATED (possibly as a
706
+ // symlink or with symlinked ancestors), and the cached verdict would
707
+ // otherwise stay trusted for up to the 10s TTL.
708
+ invalidateSymlinkSafeCache(dirPath);
709
+ ensureDirSync(dirPath);
710
+ fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, mode ?? 0o600);
711
+ }
608
712
  tempNeedsCleanup = true; // ST-7: temp file now exists on disk
609
713
  // Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
610
714
  const openedStat = fs.fstatSync(fd);
@@ -657,13 +761,16 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
657
761
  // where the Windows-specific rename path in renameWithLinkSync already
658
762
  // uses MoveFileEx which is fully durable).
659
763
  // F4: honor durability — best-effort also skips the parent-dir fsync.
764
+ // T8: inside a coalesced DRAIN (dirFsyncDeferralDepth > 0 — raised only
765
+ // by flushPendingAtomicWrites() with no argument) DEFER the dir fsync:
766
+ // accumulate the dirname in pendingDirFsyncs and let the drain issue
767
+ // ONE fsync per distinct dir after all renames. Every other caller
768
+ // runs at depth 0 and keeps this immediate dir-fsync unchanged.
660
769
  if (durability === "full" && process.platform !== "win32") {
661
- try {
662
- const dirFd = fs.openSync(path.dirname(filePath), "r");
663
- fs.fsyncSync(dirFd);
664
- fs.closeSync(dirFd);
665
- } catch {
666
- /* best-effort — not all filesystems support directory fsync */
770
+ if (dirFsyncDeferralDepth > 0) {
771
+ pendingDirFsyncs.add(path.dirname(filePath));
772
+ } else {
773
+ fsyncParentDirImmediate(filePath);
667
774
  }
668
775
  }
669
776
  } catch (renameError) {
@@ -731,16 +838,36 @@ export async function atomicWriteFileAsync(filePath: string, content: string, op
731
838
  }
732
839
  if (!isSymlinkSafeDirCached(filePath))
733
840
  throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
734
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
841
+ // PERF (2026-08-24): shared dir memo — a sync mkdir on an existing dir is one
842
+ // cheap syscall; replacing `await fs.promises.mkdir` is fine because the
843
+ // memoized hit does not throw and does not yield the event loop.
844
+ ensureDirSync(path.dirname(filePath));
735
845
  const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
736
846
  let fd: fs.promises.FileHandle | undefined;
737
847
  try {
738
848
  const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
739
- fd = await fs.promises.open(
740
- tempPath,
741
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW,
742
- mode ?? 0o600,
743
- );
849
+ // PERF (2026-08-24): same ENOENT retry as the sync path — the memoized
850
+ // dir may have been deleted between memoization and this open.
851
+ try {
852
+ fd = await fs.promises.open(
853
+ tempPath,
854
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW,
855
+ mode ?? 0o600,
856
+ );
857
+ } catch (openError) {
858
+ if ((openError as NodeJS.ErrnoException).code !== "ENOENT") throw openError;
859
+ forgetDir(path.dirname(filePath));
860
+ // Parity hardening (async twin of the sync path): drop the cached
861
+ // symlink-safety verdict alongside the dir memo so a recreated dir
862
+ // re-runs the walk on the next write.
863
+ invalidateSymlinkSafeCache(path.dirname(filePath));
864
+ ensureDirSync(path.dirname(filePath));
865
+ fd = await fs.promises.open(
866
+ tempPath,
867
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW,
868
+ mode ?? 0o600,
869
+ );
870
+ }
744
871
  // Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
745
872
  const openedStat = await fd.stat();
746
873
  if (!openedStat.isFile()) {
@@ -773,6 +900,14 @@ export async function atomicWriteFileAsync(filePath: string, content: string, op
773
900
  // some Linux filesystems, so a crash between rename and journal flush
774
901
  // could leave a stale directory entry. The sync path already does this;
775
902
  // this brings the async path to durability parity.
903
+ // T8: the grouped dir-fsync (pendingDirFsyncs) deliberately does NOT
904
+ // apply here. There is no ASYNC coalesced drain — the drain loop in
905
+ // flushPendingAtomicWrites() is fully synchronous and calls only the
906
+ // sync atomicWriteFile, so an async write can never observe
907
+ // dirFsyncDeferralDepth > 0. Deferring here would add dirs to a set
908
+ // whose trailing flush has already run (worse than not fsyncing at
909
+ // all). If an async drain is ever added, it must collect distinct dirs
910
+ // and Promise.all their fsyncs at the end of the drain.
776
911
  if (durability === "full" && process.platform !== "win32") {
777
912
  try {
778
913
  const dirFd = await fs.promises.open(path.dirname(filePath), "r");
@@ -832,7 +967,12 @@ export async function atomicWriteJsonAsync<T>(filePath: string, value: T, option
832
967
  // Auto-flush hooks: process exit / SIGTERM / SIGINT, plus an exposed
833
968
  // `flushPendingAtomicWrites()` for cleanupRuntime.
834
969
  interface CoalescedAtomicWrite {
835
- content: string;
970
+ /** PERF (2026-08-24): the value is stringified at FLUSH time, not queue
971
+ * time (see atomicWriteJsonCoalesced). Flushed content reflects the
972
+ * object's state at flush time. */
973
+ value: unknown;
974
+ /** PERF-6: formatting captured at queue time, applied at flush time. */
975
+ compact: boolean;
836
976
  timer: ReturnType<typeof setTimeout>;
837
977
  coalesceMs: number;
838
978
  retryCount: number;
@@ -880,10 +1020,16 @@ export function atomicWriteJsonCoalesced<T>(
880
1020
  atomicWriteJson(filePath, value, options);
881
1021
  return;
882
1022
  }
883
- // PERF-6: honor compact — normalize BEFORE serializing so the buffered content
884
- // uses the caller's preferred formatting.
1023
+ // PERF-6: honor compact — normalize BEFORE queueing so the buffered entry
1024
+ // carries the caller's preferred formatting for the flush-time stringify.
885
1025
  const normalized = normalizeOptions(options);
886
- const content = `${normalized.compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}\n`;
1026
+ // PERF (2026-08-24): stringify moved to FLUSH time. persistSingleTaskUpdate
1027
+ // re-saves every ~500ms per task while the coalesce window is 50ms — callers
1028
+ // that keep writing overwrite the pending entry before it ever flushes, so
1029
+ // eager stringify burned a full-array serialize per save for nothing.
1030
+ // NOTE (semantic): the flushed content reflects the object's state at flush
1031
+ // time. All current callers hand us a freshly built array and drop it; if a
1032
+ // future caller mutates after queueing, that mutation persists.
887
1033
  const previous = pendingAtomicWrites.get(filePath);
888
1034
  if (previous) clearTimeout(previous.timer);
889
1035
  const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), coalesceMs);
@@ -891,7 +1037,8 @@ export function atomicWriteJsonCoalesced<T>(
891
1037
  // Issue 2 fix: increment generation for each new entry
892
1038
  const generation = ++writeGeneration;
893
1039
  pendingAtomicWrites.set(filePath, {
894
- content,
1040
+ value,
1041
+ compact: normalized.compact === true,
895
1042
  timer,
896
1043
  coalesceMs,
897
1044
  retryCount: 0,
@@ -909,7 +1056,11 @@ function flushOnePendingAtomicWrite(filePath: string): void {
909
1056
  const savedGeneration = entry.generation;
910
1057
  clearTimeout(entry.timer);
911
1058
  try {
912
- atomicWriteFile(filePath, entry.content, { durability: entry.durability });
1059
+ // PERF (2026-08-24): stringify happens HERE (flush time), not at queue
1060
+ // time — see the note in atomicWriteJsonCoalesced. No defensive deep-copy
1061
+ // of entry.value: that would reintroduce the cost this change removes.
1062
+ const content = `${entry.compact ? JSON.stringify(entry.value) : JSON.stringify(entry.value, null, 2)}\n`;
1063
+ atomicWriteFile(filePath, content, { durability: entry.durability });
913
1064
  // Issue 2 fix: Verify generation hasn't changed before deleting.
914
1065
  // A concurrent write may have replaced entry with a newer one during the flush.
915
1066
  // Only delete if generation matches (not a newer entry).
@@ -919,25 +1070,34 @@ function flushOnePendingAtomicWrite(filePath: string): void {
919
1070
  }
920
1071
  } catch (error) {
921
1072
  logInternalError("atomic-write.coalesced-flush", error, filePath, "error");
922
- // Issue 1 fix: set a fresh timer for failed entries before returning.
923
- // This ensures failed entries are retried without waiting for another
924
- // write to arrive. Only set timer if this entry is still current
925
- // (not replaced by a newer write during the flush).
1073
+ // ISSUE (2026-08-26, dead-retry-path fix): atomicWriteFile's first
1074
+ // statement cancels the pending entry (cancelPendingCoalescedWrite),
1075
+ // so when the write throws, the map no longer holds this entry — the
1076
+ // previous retry logic looked the map up, found nothing, and silently
1077
+ // dropped the buffered write (no retry, no propagation). RE-ADD the
1078
+ // captured entry (unless a NEWER write re-queued during the flush —
1079
+ // its generation would differ) so the backoff retry below fires on
1080
+ // real data instead of a phantom lookup.
926
1081
  const current = pendingAtomicWrites.get(filePath);
927
- if (current?.generation === savedGeneration) {
928
- current.retryCount++;
929
- if (current.retryCount >= MAX_FLUSH_RETRIES) {
930
- // Max retries exceeded - remove entry and propagate error to callers
931
- pendingAtomicWrites.delete(filePath);
932
- // Re-throw so callers can handle the persistent failure
933
- throw error;
934
- }
935
- // Exponential backoff: base delay * 2^(retryCount-1), capped at 30 seconds
936
- const backoffMs = Math.min(30000, current.coalesceMs * 2 ** (current.retryCount - 1));
937
- const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), backoffMs);
938
- timer.unref();
939
- current.timer = timer;
1082
+ if (current !== undefined && current.generation !== savedGeneration) {
1083
+ // A newer write arrived during the flush — leave it alone.
1084
+ return;
1085
+ }
1086
+ const entryToRetry = current ?? entry;
1087
+ entryToRetry.retryCount++;
1088
+ if (entryToRetry.retryCount >= MAX_FLUSH_RETRIES) {
1089
+ // Max retries exceeded - remove entry and propagate error to callers
1090
+ pendingAtomicWrites.delete(filePath);
1091
+ // Re-throw so callers can handle the persistent failure
1092
+ throw error;
940
1093
  }
1094
+ // Exponential backoff: base delay * 2^(retryCount-1), capped at 30 seconds
1095
+ const backoffMs = Math.min(30000, entryToRetry.coalesceMs * 2 ** (entryToRetry.retryCount - 1));
1096
+ clearTimeout(entryToRetry.timer);
1097
+ const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), backoffMs);
1098
+ timer.unref();
1099
+ entryToRetry.timer = timer;
1100
+ pendingAtomicWrites.set(filePath, entryToRetry);
941
1101
  }
942
1102
  }
943
1103
 
@@ -975,18 +1135,37 @@ function cancelPendingCoalescedWrite(filePath: string): void {
975
1135
  * Re-entrancy guard (`flushInProgress`) applies to both modes: a scoped flush
976
1136
  * triggered while a global flush is running is a no-op (the global flush
977
1137
  * already covers this file), and vice versa.
1138
+ *
1139
+ * T8: the GLOBAL drain (no argument) groups the parent-dir fsync — each
1140
+ * flushed file keeps its data fsync but defers the dir fsync, and after the
1141
+ * serial loop completes (all renames done → R16-B1 ordering) ONE fsync is
1142
+ * issued per distinct pending dir. The trailing fsync lives in the `finally`
1143
+ * so a mid-drain throw (a flush that exhausted MAX_FLUSH_RETRIES) still
1144
+ * makes the already-renamed files crash-durable. Scoped flushes and
1145
+ * coalesce-timer flushes keep the immediate per-file dir fsync.
978
1146
  */
979
1147
  export function flushPendingAtomicWrites(filePath?: string): void {
980
1148
  if (flushInProgress > 0) return;
981
1149
  flushInProgress++;
1150
+ const drainAll = filePath === undefined;
1151
+ if (drainAll) dirFsyncDeferralDepth++;
982
1152
  try {
983
- if (filePath === undefined) {
1153
+ if (drainAll) {
984
1154
  for (const pending of [...pendingAtomicWrites.keys()]) flushOnePendingAtomicWrite(pending);
985
1155
  } else if (pendingAtomicWrites.has(filePath)) {
986
1156
  flushOnePendingAtomicWrite(filePath);
987
1157
  }
988
1158
  } finally {
989
- flushInProgress--;
1159
+ try {
1160
+ if (drainAll) {
1161
+ // Decrement BEFORE flushing the deferred dirs so nothing inside
1162
+ // the trailing fsync could observe a deferral scope.
1163
+ dirFsyncDeferralDepth--;
1164
+ fsyncPendingParentDirs();
1165
+ }
1166
+ } finally {
1167
+ flushInProgress--;
1168
+ }
990
1169
  }
991
1170
  }
992
1171
 
@@ -5,7 +5,7 @@ import * as path from "node:path";
5
5
  import { DEFAULT_LOCKS } from "../../config/defaults.ts";
6
6
  import { logInternalError } from "../../utils/internal-error.ts";
7
7
  import { sleepSync } from "../../utils/sleep.ts";
8
- import { isSymlinkSafePath } from "../atomic-write.ts";
8
+ import { ensureDirSync, isSymlinkSafeDirCached, isSymlinkSafePath } from "../atomic-write.ts";
9
9
  import type { TeamRunManifest } from "../types.ts";
10
10
 
11
11
  export interface RunLockOptions {
@@ -415,10 +415,12 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
415
415
  if (fileLockHeldByUs.get(lockFile)) {
416
416
  return fn();
417
417
  }
418
- // FIX: Validate the parent directory is not a symlink BEFORE calling mkdirSync.
419
- // Between mkdir and lock acquisition, an attacker could plant a symlink.
420
- if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
421
- fs.mkdirSync(path.dirname(lockFile), { recursive: true });
418
+ // PERF (2026-08-24): pre-loop check uses the 10s-TTL cached verdict (same
419
+ // tradeoff atomic writes already make). The RETRY loop below keeps the
420
+ // UNCACHED re-validation — TOCTOU rigor is preserved exactly where a race
421
+ // is actually being retried.
422
+ if (!isSymlinkSafeDirCached(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
423
+ ensureDirSync(path.dirname(lockFile));
422
424
  // Round 26 (BUG 2): REMOVED the pre-acquisition target-file-existence check.
423
425
  // It was racy — between statSync(target) and acquire, a concurrent process
424
426
  // could acquire the lock to CREATE the target, and we'd delete its active
@@ -9,7 +9,7 @@ import { withFileLockAsync, withFileLockSync } from "./locks.ts";
9
9
 
10
10
  export type MailboxDirection = "inbox" | "outbox";
11
11
  export type MailboxMessageStatus = "queued" | "delivered" | "acknowledged";
12
- export type MailboxMessageKind = "message" | "steer" | "follow-up" | "response" | "group_join";
12
+ export type MailboxMessageKind = "message" | "notify" | "steer" | "follow-up" | "response" | "group_join";
13
13
  export type MailboxMessagePriority = "urgent" | "normal" | "low";
14
14
  export type MailboxDeliveryMode = "interrupt" | "next_turn";
15
15
 
@@ -257,7 +257,14 @@ function isStatus(value: unknown): value is MailboxMessageStatus {
257
257
  }
258
258
 
259
259
  function isKind(value: unknown): value is MailboxMessageKind {
260
- return value === "message" || value === "steer" || value === "follow-up" || value === "response" || value === "group_join";
260
+ return (
261
+ value === "message" ||
262
+ value === "notify" ||
263
+ value === "steer" ||
264
+ value === "follow-up" ||
265
+ value === "response" ||
266
+ value === "group_join"
267
+ );
261
268
  }
262
269
 
263
270
  function isPriority(value: unknown): value is MailboxMessagePriority {
@@ -309,8 +316,9 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
309
316
  };
310
317
  }
311
318
 
312
- function readMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
313
- if (!fs.existsSync(filePath)) return [];
319
+ /** Raw read+parse of one mailbox JSONL file (extracted verbatim from the old
320
+ * readMailboxFile body). Callers go through cachedMailboxRead instead. */
321
+ function parseMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
314
322
  const messages: MailboxMessage[] = [];
315
323
  const raw = fs.readFileSync(filePath, "utf-8");
316
324
  for (const line of raw.split(/\r?\n/).filter(Boolean)) {
@@ -324,18 +332,50 @@ function readMailboxFile(filePath: string, direction: MailboxDirection): Mailbox
324
332
  return messages;
325
333
  }
326
334
 
335
+ // PERF (2026-08-24): parked workers poll all mailboxes every 500ms and used to
336
+ // read+parse every file each tick. Parse results are now memoized per file by
337
+ // (mtime, size); append/rotate change mtime so invalidation is automatic.
338
+ // Entries hold the parsed array; readers get a shallow copy (array of refs) —
339
+ // 100x cheaper than re-reading, and callers never mutate message objects.
340
+ const mailboxParseCache = new Map<string, { mtimeMs: number; size: number; messages: MailboxMessage[] }>();
341
+ const MAILBOX_PARSE_CACHE_MAX = 128;
342
+ function cachedMailboxRead(filePath: string, direction: MailboxDirection): MailboxMessage[] {
343
+ let stat: fs.Stats;
344
+ try {
345
+ stat = fs.statSync(filePath);
346
+ } catch {
347
+ // Missing (or vanished, e.g. pruned archive) → drop any stale entry.
348
+ mailboxParseCache.delete(filePath);
349
+ return [];
350
+ }
351
+ const hit = mailboxParseCache.get(filePath);
352
+ if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.messages.slice();
353
+ const messages = parseMailboxFile(filePath, direction);
354
+ if (mailboxParseCache.size >= MAILBOX_PARSE_CACHE_MAX) {
355
+ const oldest = mailboxParseCache.keys().next().value;
356
+ if (oldest !== undefined) mailboxParseCache.delete(oldest);
357
+ }
358
+ mailboxParseCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, messages });
359
+ return messages.slice();
360
+ }
361
+
327
362
  function safeReadMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
328
- if (!fs.existsSync(filePath)) return [];
329
- const messages: MailboxMessage[] = readMailboxFile(filePath, direction);
363
+ // PERF: stat-gated primary read — ENOENT is handled inside cachedMailboxRead.
364
+ const messages = cachedMailboxRead(filePath, direction);
330
365
  // 3.3 — also include any rotated archive files alongside the live file.
331
366
  // Archive naming: `<filename>.<isoTimestamp>.archive.jsonl`.
367
+ // PERF: archives are immutable once written (rotation only ever creates
368
+ // new ones — see rotateMailboxFileIfNeeded's rename + atomicWriteFile
369
+ // sequence), so they route through cachedMailboxRead too and permanently
370
+ // hit after the first parse. pruneOldMailboxArchives deleting an archive
371
+ // is covered by cachedMailboxRead's ENOENT → cache-delete branch.
332
372
  try {
333
373
  const dir = path.dirname(filePath);
334
374
  const base = path.basename(filePath);
335
375
  for (const entry of fs.readdirSync(dir)) {
336
376
  if (!entry.startsWith(`${base}.`) || !entry.endsWith(".archive.jsonl")) continue;
337
377
  const archivePath = path.join(dir, entry);
338
- messages.push(...readMailboxFile(archivePath, direction));
378
+ messages.push(...cachedMailboxRead(archivePath, direction));
339
379
  }
340
380
  } catch {
341
381
  // Directory missing — nothing to read.
@@ -610,8 +650,10 @@ export function appendMailboxMessage(
610
650
  const delivery = readDeliveryState(manifest);
611
651
  delivery.messages[complete.id] = complete.status;
612
652
  delivery.updatedAt = createdAt;
613
- // F4: complete transitions are terminal-ish — keep full durability.
614
- writeDeliveryState(manifest, delivery, { durability: "full" });
653
+ // F4: delivery state is informational and overwritten by the next message —
654
+ // drop explicit full durability so the default (best-effort) applies; a hard
655
+ // crash only risks re-delivery, the accepted semantics of the default path.
656
+ writeDeliveryState(manifest, delivery);
615
657
  });
616
658
  notifyMailboxAppended(complete);
617
659
  return complete;
@@ -721,7 +763,11 @@ export async function appendMailboxMessageAsync(
721
763
  const delivery = readDeliveryState(manifest);
722
764
  delivery.messages[complete.id] = complete.status;
723
765
  delivery.updatedAt = createdAt;
724
- writeDeliveryState(manifest, delivery, { durability: "full" });
766
+ // PERF round 2 (mirror of the sync-twin fix at :647): delivery.json is
767
+ // informational and the next message overwrites it — drop the forced
768
+ // full durability so the default (best-effort) applies here too. A
769
+ // crash risks re-delivery only, the accepted default-path semantics.
770
+ writeDeliveryState(manifest, delivery);
725
771
  });
726
772
  notifyMailboxAppended(complete);
727
773
  return complete;