pi-crew 0.9.42 → 0.9.46

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 (60) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +35 -0
  3. package/dist/build-meta.json +4778 -11087
  4. package/dist/index.mjs +54648 -65435
  5. package/dist/index.mjs.map +4 -4
  6. package/package.json +1 -1
  7. package/scripts/build-bundle.mjs +2 -1
  8. package/src/agents/agent-config.ts +1 -1
  9. package/src/agents/discover-agents.ts +1 -1
  10. package/src/config/config.ts +1 -1
  11. package/src/config/types.ts +0 -2
  12. package/src/extension/crew-vibes/config.ts +1 -1
  13. package/src/extension/crew-vibes/font-detect.ts +16 -3
  14. package/src/extension/cross-extension-rpc.ts +8 -4
  15. package/src/extension/registration/commands.ts +5 -1
  16. package/src/extension/registration/foreground-run-controller.ts +28 -0
  17. package/src/extension/registration/lifecycle-handlers.ts +34 -3
  18. package/src/extension/registration/subagent-manager-setup.ts +178 -59
  19. package/src/extension/run-import.ts +21 -1
  20. package/src/extension/team-tool/api.ts +4 -2
  21. package/src/extension/team-tool.ts +5 -0
  22. package/src/prompt/prompt-runtime.ts +15 -0
  23. package/src/runtime/async-runner.ts +9 -1
  24. package/src/runtime/child-pi-constants.ts +42 -0
  25. package/src/runtime/child-pi-kill.ts +180 -0
  26. package/src/runtime/child-pi-spawn.ts +234 -0
  27. package/src/runtime/child-pi-steering.ts +128 -0
  28. package/src/runtime/child-pi-streams.ts +296 -0
  29. package/src/runtime/child-pi-transcript.ts +169 -0
  30. package/src/runtime/child-pi.ts +75 -837
  31. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  32. package/src/runtime/dwf-state-store.ts +5 -0
  33. package/src/runtime/dynamic-workflow-context.ts +78 -18
  34. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  35. package/src/runtime/goal-evaluator.ts +59 -27
  36. package/src/runtime/goal-loop-runner.ts +40 -11
  37. package/src/runtime/live-agent-manager.ts +18 -0
  38. package/src/runtime/manifest-cache.ts +30 -0
  39. package/src/runtime/pi-json-output.ts +2 -1
  40. package/src/runtime/pi-spawn.ts +7 -1
  41. package/src/runtime/resilient-edit.ts +16 -15
  42. package/src/runtime/role-permission.ts +27 -2
  43. package/src/runtime/run-coalesced-task-group.ts +90 -25
  44. package/src/runtime/task-packet.ts +1 -1
  45. package/src/runtime/task-runner/prompt-builder.ts +2 -2
  46. package/src/runtime/team-runner.ts +6 -1
  47. package/src/state/event-log.ts +89 -34
  48. package/src/state/locks.ts +185 -49
  49. package/src/state/mailbox.ts +165 -4
  50. package/src/state/run-metrics.ts +40 -12
  51. package/src/state/worker-atomic-writer.ts +1 -0
  52. package/src/ui/live-run-sidebar.ts +1 -9
  53. package/src/ui/run-dashboard.ts +1 -9
  54. package/src/utils/incremental-reader.ts +105 -0
  55. package/src/utils/paths.ts +1 -1
  56. package/src/utils/visual.ts +27 -91
  57. package/src/worktree/worktree-manager.ts +47 -19
  58. package/src/runtime/auto-resume.ts +0 -100
  59. package/src/runtime/notebook-helpers.ts +0 -88
  60. package/src/runtime/orphan-sentinel.ts +0 -7
@@ -13,6 +13,111 @@ export interface IncrementalReadResult {
13
13
 
14
14
  const CHUNK_SIZE = 64 * 1024;
15
15
 
16
+ /**
17
+ * Read the last `tailBytes` bytes of a file and parse it as JSONL,
18
+ * returning each successfully-parsed item in file order. If the file is
19
+ * shorter than `tailBytes`, the entire file is read from offset 0.
20
+ *
21
+ * Returns:
22
+ * - items: parsed JSONL items (T may be any deserializable type)
23
+ * - fileSize: total file size in bytes
24
+ * - bytesRead: number of bytes actually read (≤ min(fileSize, tailBytes))
25
+ * - truncated: true when `bytesRead < fileSize` (a prefix was dropped)
26
+ *
27
+ * The function bounds the read to `tailBytes`, so CPU is O(tail bytes)
28
+ * regardless of how large the file grows. Used by the event-log tail
29
+ * read path (FIND-05) to avoid the O(total events) cost of the legacy
30
+ * `readFileSync`+`split("\n")`+`JSON.parse` approach.
31
+ */
32
+ export function readJsonlTail<T>(
33
+ filePath: string,
34
+ tailBytes: number,
35
+ ): {
36
+ items: T[];
37
+ fileSize: number;
38
+ bytesRead: number;
39
+ truncated: boolean;
40
+ } {
41
+ const limit = Math.max(0, Math.floor(tailBytes));
42
+ let stat: fs.Stats;
43
+ try {
44
+ stat = fs.statSync(filePath);
45
+ } catch {
46
+ return { items: [], fileSize: 0, bytesRead: 0, truncated: false };
47
+ }
48
+ const fileSize = stat.size;
49
+ if (fileSize === 0 || limit === 0) {
50
+ return { items: [], fileSize, bytesRead: 0, truncated: false };
51
+ }
52
+
53
+ const startOffset = Math.max(0, fileSize - limit);
54
+ const bytesToRead = fileSize - startOffset;
55
+ const truncated = startOffset > 0;
56
+
57
+ let fd: number | undefined;
58
+ try {
59
+ // FIND-05 follow-up: O_NOFOLLOW refuses symlinks (defense-in-depth,
60
+ // consistent with atomicWriteFile / resolveRealContainedPath). The
61
+ // eventsPath is validated inside stateRoot, but this guards against a
62
+ // symlinked file appearing after validation. O_NOFOLLOW on a symlink
63
+ // yields ELOOP, which the catch below treats like ENOENT (return empty).
64
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
65
+ } catch {
66
+ return { items: [], fileSize, bytesRead: 0, truncated: false };
67
+ }
68
+ try {
69
+ const buf = Buffer.alloc(bytesToRead);
70
+ let totalRead = 0;
71
+ while (totalRead < bytesToRead) {
72
+ const chunkSize = Math.min(CHUNK_SIZE, bytesToRead - totalRead);
73
+ const bytesRead = fs.readSync(fd, buf, totalRead, chunkSize, startOffset + totalRead);
74
+ if (bytesRead === 0) break;
75
+ totalRead += bytesRead;
76
+ }
77
+
78
+ // Decode the slice we actually read; the buffer may be partially
79
+ // filled on short reads. We use totalRead (not bytesToRead) so the
80
+ // JSONL split lands on actual data, not a zero-padded tail.
81
+ const content = buf.toString("utf-8", 0, totalRead);
82
+
83
+ // If we truncated the file, the first decoded line may be a
84
+ // half-line from the cut. Drop it — we can't trust the JSON.
85
+ // Use a single indexOf("\n") to find the first newline; if there
86
+ // is none, the whole tail is one partial line and we drop it.
87
+ let body = content;
88
+ if (truncated) {
89
+ const firstNewline = body.indexOf("\n");
90
+ if (firstNewline < 0) {
91
+ // No newline at all in the tail → the entire tail is one
92
+ // partial line. Return no items; the caller should fall
93
+ // back to a wider tail or full read.
94
+ return { items: [], fileSize, bytesRead: totalRead, truncated: true };
95
+ }
96
+ body = body.slice(firstNewline + 1);
97
+ }
98
+
99
+ const items: T[] = [];
100
+ for (const line of body.split("\n")) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed) continue;
103
+ try {
104
+ items.push(JSON.parse(trimmed) as T);
105
+ } catch {
106
+ // Skip malformed lines (mirrors readJsonlSince's behavior).
107
+ }
108
+ }
109
+ return { items, fileSize, bytesRead: totalRead, truncated };
110
+ } finally {
111
+ if (fd !== undefined) {
112
+ try {
113
+ fs.closeSync(fd);
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ }
118
+ }
119
+ }
120
+
16
121
  /**
17
122
  * Read new lines from a text file since last known byte offset.
18
123
  * Uses fs.openSync + fs.readSync for efficient incremental reading.
@@ -44,7 +44,7 @@ export function userPiRoot(): string {
44
44
  // "undefined/.pi/agent" relative to cwd and silently create a junk "undefined/"
45
45
  // tree. Treat the literal "undefined" (and empty) as unset and fall back to
46
46
  // os.homedir().
47
- const rawHome = process.env.PI_TEAMS_HOME?.trim();
47
+ const rawHome = (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim();
48
48
  const home = rawHome && rawHome !== "undefined" ? rawHome : os.homedir();
49
49
  const resolved = path.join(home, ".pi", "agent");
50
50
 
@@ -1,98 +1,28 @@
1
+ import { visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
2
+
1
3
  export const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
2
4
 
3
5
  const WIDTH_CACHE_LIMIT = 256;
4
6
  const widthCache = new Map<string, number>();
5
7
 
6
- /** Code-point ranges that render as width 2 in most terminals (CJK + emoji). */
7
- const WIDE_RANGES: Array<[number, number]> = [
8
- // CJK Unified Ideographs
9
- [0x4e00, 0x9fff],
10
- // CJK Extension A
11
- [0x3400, 0x4dbf],
12
- // CJK Compatibility Ideographs
13
- [0xf900, 0xfaff],
14
- // Hangul Syllables
15
- [0xac00, 0xd7af],
16
- // CJK Symbols and Punctuation, Hiragana, Katakana
17
- [0x3000, 0x33ff],
18
- // Fullwidth forms
19
- [0xff01, 0xff60],
20
- // Emoji blocks
21
- // Emoji-presentation codepoints in 0x2600-0x27BF (narrow chars like ✓✗★ excluded)
22
- [0x2615, 0x2615],
23
- [0x2648, 0x2653],
24
- [0x267f, 0x267f],
25
- [0x2693, 0x2693],
26
- [0x26a1, 0x26a1],
27
- [0x26aa, 0x26ab],
28
- [0x26bd, 0x26be],
29
- [0x26c4, 0x26c5],
30
- [0x26ce, 0x26ce],
31
- [0x26d4, 0x26d4],
32
- [0x26ea, 0x26ea],
33
- [0x26f2, 0x26f3],
34
- [0x26f5, 0x26f5],
35
- [0x26fa, 0x26fa],
36
- [0x26fd, 0x26fd],
37
- [0x2702, 0x2702],
38
- [0x2705, 0x2705],
39
- [0x2708, 0x270d],
40
- [0x270f, 0x270f],
41
- [0x2712, 0x2712],
42
- [0x2714, 0x2714],
43
- [0x2716, 0x2716],
44
- [0x271d, 0x271d],
45
- [0x2721, 0x2721],
46
- [0x2728, 0x2728],
47
- [0x2733, 0x2734],
48
- [0x2744, 0x2744],
49
- [0x2747, 0x2747],
50
- [0x274c, 0x274c],
51
- [0x274e, 0x274e],
52
- [0x2753, 0x2755],
53
- [0x2757, 0x2757],
54
- [0x2763, 0x2764],
55
- [0x2795, 0x2797],
56
- [0x27a1, 0x27a1],
57
- [0x27b0, 0x27b0],
58
- [0x27bf, 0x27bf],
59
- // Geometric Shapes / Misc Symbols-Arrows emoji that pi-tui upstream counts as
60
- // width=2 (RGI emoji). Mismatch here caused the "Rendered line N exceeds
61
- // terminal width (160 > 159)" TUI crash: pi-crew truncated to width 159 by
62
- // its own (mismatched) measure, then Box padded to 159 chars, but pi-tui
63
- // re-measured the padded line at 160 because ⬜ counts as 2 upstream.
64
- [0x2b1b, 0x2b1c], // ⬛ BLACK LARGE SQUARE, ⬜ WHITE LARGE SQUARE
65
- [0x1f300, 0x1f9ff], // Misc Symbols, Emoticons, Transport, Map, Supplement
66
- [0x1fa00, 0x1faff], // Symbols Extended-A
67
- [0x1f000, 0x1f02f], // Mahjong, Dominos
68
- [0xfe00, 0xfe0f], // Variation Selectors (emoji presentation)
69
- ];
70
- // NOTE: U+200D (Zero Width Joiner, ZWJ) is intentionally NOT listed as wide.
71
- // ZWJ has zero advance width by Unicode definition; miscounting it as 2 caused
72
- // slight over-truncation/over-padding of compound-emoji goals (T-1).
73
-
74
- function isWideCodePoint(code: number): boolean {
75
- for (const [lo, hi] of WIDE_RANGES) {
76
- if (code >= lo && code <= hi) return true;
77
- }
78
- return false;
79
- }
8
+ // NOTE: width measurement is delegated to pi-tui's `visibleWidth` (see below).
9
+ // pi-tui is the renderer that HARD-ABORTS the session if any line exceeds the
10
+ // terminal width by ITS measure, so our truncate/pad/wrap MUST agree with it.
11
+ // A previous hand-maintained WIDE_RANGES table diverged from pi-tui's
12
+ // grapheme+RGI-emoji model on codepoints like ⏳ U+23F3 (hourglass: we counted
13
+ // 1, pi-tui counts 2) → lines we believed fit were rejected →
14
+ // "Rendered line N exceeds terminal width (160 > 159)" crash. Delegating makes
15
+ // divergence structurally impossible.
80
16
 
81
17
  export function visibleWidth(value: string): number {
82
- // Skip caching for very long strings to avoid memory pressure.
83
- if (value.length > 4096) {
84
- let length = 0;
85
- for (const char of value.replace(ANSI_PATTERN, "")) {
86
- if (char !== "\n") length += isWideCodePoint(char.codePointAt(0) ?? 0) ? 2 : 1;
87
- }
88
- return length;
89
- }
18
+ // Delegate to pi-tui's authoritative width model so our truncate/pad/wrap
19
+ // can never disagree with the renderer's doRender measurement. Keep our own
20
+ // LRU cache on top (pi-tui caches too, but this avoids repeated calls for the
21
+ // hot render path).
22
+ if (value.length > 4096) return tuiVisibleWidth(value);
90
23
  const cached = widthCache.get(value);
91
24
  if (cached !== undefined) return cached;
92
- let length = 0;
93
- for (const char of value.replace(ANSI_PATTERN, "")) {
94
- if (char !== "\n") length += isWideCodePoint(char.codePointAt(0) ?? 0) ? 2 : 1;
95
- }
25
+ const length = tuiVisibleWidth(value);
96
26
  if (widthCache.size >= WIDTH_CACHE_LIMIT) {
97
27
  const firstKey = widthCache.keys().next().value;
98
28
  if (firstKey !== undefined) widthCache.delete(firstKey);
@@ -132,15 +62,21 @@ export function truncateToWidth(value: string, width: number, ellipsis = "…"):
132
62
  if (width <= ellipsis.length) return ellipsis.slice(0, width);
133
63
  let output = "";
134
64
  let renderedWidth = 0;
135
- for (let i = 0; i < value.length; i++) {
65
+ for (let i = 0; i < value.length; ) {
136
66
  const ansiLen = consumeAnsi(value, i);
137
67
  if (ansiLen) {
138
68
  output += value.slice(i, i + ansiLen);
139
- i += ansiLen - 1;
69
+ i += ansiLen;
140
70
  continue;
141
71
  }
142
- const char = value[i] as string;
143
- const nextIndex = (char.codePointAt(0) ?? 0) > 0xffff ? i + 2 : i + 1;
72
+ // Read the codepoint at position i on the FULL string — NOT
73
+ // `value[i].codePointAt(0)`, which for a surrogate pair returns the
74
+ // lone half (≤ 0xFFFF) and slices each half separately. pi-tui's
75
+ // visibleWidth zero-widths lone `\p{Surrogate}` halves, so 🤖 would
76
+ // undercount as 0 and truncation would never cut (latent bug unmasked
77
+ // once visibleWidth delegated to pi-tui).
78
+ const codepoint = value.codePointAt(i) ?? 0;
79
+ const nextIndex = codepoint > 0xffff ? i + 2 : i + 1;
144
80
  const segment = value.slice(i, nextIndex);
145
81
  const charWidth = visibleWidth(segment);
146
82
  if (renderedWidth + charWidth > width - ellipsis.length) {
@@ -148,7 +84,7 @@ export function truncateToWidth(value: string, width: number, ellipsis = "…"):
148
84
  }
149
85
  output += segment;
150
86
  renderedWidth += charWidth;
151
- i = nextIndex - 1;
87
+ i = nextIndex;
152
88
  }
153
89
  return output;
154
90
  }
@@ -134,7 +134,11 @@ export function findGitRoot(cwd: string): string {
134
134
  }
135
135
 
136
136
  export function assertCleanLeader(repoRoot: string): void {
137
- const status = git(repoRoot, ["status", "--porcelain"]);
137
+ // H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
138
+ // (and any other untracked files the user hasn't staged) doesn't block worktree mode.
139
+ // The worktree contract is "no tracked changes" — untracked files are safe since
140
+ // they're either in .gitignore or the user can decide later.
141
+ const status = git(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
138
142
  if (status.trim()) {
139
143
  throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
140
144
  }
@@ -168,7 +172,9 @@ export function clearCleanLeaderCache(): void {
168
172
 
169
173
  export async function assertCleanLeaderAsync(repoRoot: string): Promise<void> {
170
174
  if (_cleanLeaderCache.has(repoRoot)) return;
171
- const status = await gitAsync(repoRoot, ["status", "--porcelain"]);
175
+ // H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
176
+ // (and any other untracked files the user hasn't staged) doesn't block worktree mode.
177
+ const status = await gitAsync(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
172
178
  if (status.trim()) {
173
179
  throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
174
180
  }
@@ -558,7 +564,7 @@ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPat
558
564
  * recovered via `git apply` / manual restore. Best-effort: a snapshot failure
559
565
  * only logs (it must not block the clean-slate reuse flow).
560
566
  */
561
- function snapshotDirtyWorktree(manifest: TeamRunManifest, task: TeamTaskState, worktreePath: string, dirtyStatus: string): void {
567
+ function snapshotDirtyWorktree(manifest: TeamRunManifest, task: TeamTaskState, worktreePath: string, dirtyStatus: string): boolean {
562
568
  try {
563
569
  const parts: string[] = [
564
570
  `# Worktree recovery snapshot`,
@@ -596,12 +602,14 @@ function snapshotDirtyWorktree(manifest: TeamRunManifest, task: TeamTaskState, w
596
602
  producer: "worktree-manager.snapshotDirtyWorktree",
597
603
  retention: "run",
598
604
  });
605
+ return true;
599
606
  } catch (err) {
600
607
  logInternalError(
601
608
  "worktree.recovery.snapshotFailed",
602
609
  err instanceof Error ? err : new Error(String(err)),
603
610
  `runId=${manifest.runId}, taskId=${task.id}`,
604
611
  );
612
+ return false;
605
613
  }
606
614
  }
607
615
 
@@ -722,14 +730,24 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
722
730
  if (dirtyStatus.trim()) {
723
731
  // Snapshot uncommitted work to a recovery artifact BEFORE discarding, so the
724
732
  // previous run's changes are never silently destroyed on reuse.
725
- snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
726
- logInternalError(
727
- "worktree.reused.dirty",
728
- new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
729
- `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
730
- );
731
- git(worktreePath, ["checkout", "--", "."]);
732
- git(worktreePath, ["clean", "-fd"]);
733
+ const snapshotOk = snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
734
+ if (snapshotOk) {
735
+ logInternalError(
736
+ "worktree.reused.dirty",
737
+ new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
738
+ `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
739
+ );
740
+ git(worktreePath, ["checkout", "--", "."]);
741
+ git(worktreePath, ["clean", "-fd"]);
742
+ } else {
743
+ logInternalError(
744
+ "worktree.reused.dirtyPreserved",
745
+ new Error(
746
+ `Snapshot failed — preserving dirty worktree at ${worktreePath} (skipping git checkout/clean to prevent data loss)`,
747
+ ),
748
+ `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
749
+ );
750
+ }
733
751
  }
734
752
  // Overlay seed paths from config + step-level seedPaths (reused worktree)
735
753
  const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
@@ -869,14 +887,24 @@ export async function prepareTaskWorkspaceAsync(
869
887
  }
870
888
  const dirtyStatus = await gitAsync(worktreePath, ["status", "--porcelain"]);
871
889
  if (dirtyStatus.trim()) {
872
- snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
873
- logInternalError(
874
- "worktree.reused.dirty",
875
- new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
876
- `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
877
- );
878
- await gitAsync(worktreePath, ["checkout", "--", "."]);
879
- await gitAsync(worktreePath, ["clean", "-fd"]);
890
+ const snapshotOk = snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
891
+ if (snapshotOk) {
892
+ logInternalError(
893
+ "worktree.reused.dirty",
894
+ new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
895
+ `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
896
+ );
897
+ await gitAsync(worktreePath, ["checkout", "--", "."]);
898
+ await gitAsync(worktreePath, ["clean", "-fd"]);
899
+ } else {
900
+ logInternalError(
901
+ "worktree.reused.dirtyPreserved",
902
+ new Error(
903
+ `Snapshot failed — preserving dirty worktree at ${worktreePath} (skipping git checkout/clean to prevent data loss)`,
904
+ ),
905
+ `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
906
+ );
907
+ }
880
908
  }
881
909
  const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
882
910
  const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
@@ -1,100 +0,0 @@
1
- /**
2
- * Auto-resume controller with settle window — prevents premature context injection
3
- * by waiting for Pi's setTimeout(0) + setTimeout(100) to drain before resuming.
4
- *
5
- * Distilled from pi-autoresearch's auto-resume / settle-window pattern.
6
- */
7
-
8
- /** Time to wait before auto-resuming (ms). Outlasts Pi's setTimeout(0) + setTimeout(100). */
9
- export const SETTLE_WINDOW_MS = 800;
10
-
11
- /** Maximum number of auto-resume turns before forcing a stop. */
12
- export const MAX_AUTORESUME_TURNS = 20;
13
-
14
- /**
15
- * Controller for scheduling auto-resume actions with a settle window.
16
- *
17
- * The settle window ensures that pending async operations (e.g., Pi's
18
- * internal setTimeout(0) and setTimeout(100) callbacks) have completed
19
- * before the resume callback fires.
20
- */
21
- export class AutoResumeController {
22
- private timerId: ReturnType<typeof setTimeout> | null = null;
23
- private turnCount = 0;
24
- private cancelled = false;
25
-
26
- /**
27
- * Get the current auto-resume turn count.
28
- */
29
- get currentTurnCount(): number {
30
- return this.turnCount;
31
- }
32
-
33
- /**
34
- * Get the maximum allowed auto-resume turns.
35
- */
36
- get maxTurns(): number {
37
- return MAX_AUTORESUME_TURNS;
38
- }
39
-
40
- /**
41
- * Schedule an auto-resume callback after the settle window elapses.
42
- *
43
- * If a resume is already pending, it is cancelled first (debounce behavior).
44
- * If the turn limit has been reached, the callback is not scheduled.
45
- *
46
- * @param message - Description of why the resume is being scheduled
47
- * @param callback - Function to call after the settle window
48
- */
49
- scheduleResume(message: string, callback: () => void): void {
50
- // Cancel any existing pending resume
51
- this.cancelResume();
52
-
53
- // Enforce turn limit
54
- if (this.turnCount >= MAX_AUTORESUME_TURNS) {
55
- return;
56
- }
57
-
58
- this.cancelled = false;
59
- this.turnCount++;
60
-
61
- this.timerId = setTimeout(() => {
62
- if (!this.cancelled) {
63
- this.timerId = null;
64
- callback();
65
- }
66
- }, SETTLE_WINDOW_MS);
67
-
68
- // Prevent the timer from keeping the process alive
69
- if (this.timerId && typeof this.timerId === "object" && "unref" in this.timerId) {
70
- this.timerId.unref();
71
- }
72
- }
73
-
74
- /**
75
- * Cancel any pending auto-resume.
76
- */
77
- cancelResume(): void {
78
- if (this.timerId !== null) {
79
- clearTimeout(this.timerId);
80
- this.timerId = null;
81
- }
82
- this.cancelled = true;
83
- }
84
-
85
- /**
86
- * Check whether an auto-resume is currently pending.
87
- */
88
- hasPendingResume(): boolean {
89
- return this.timerId !== null;
90
- }
91
-
92
- /**
93
- * Reset the turn counter. Called when a new agent starts processing
94
- * to allow a fresh set of auto-resume turns.
95
- */
96
- resetTurnCount(): void {
97
- this.turnCount = 0;
98
- this.cancelled = false;
99
- }
100
- }
@@ -1,88 +0,0 @@
1
- export interface NotebookCell {
2
- index: number;
3
- cellType: "code" | "markdown" | "raw";
4
- source: string;
5
- outputs?: unknown[];
6
- metadata?: Record<string, unknown>;
7
- }
8
-
9
- export interface Notebook {
10
- cells: NotebookCell[];
11
- metadata?: Record<string, unknown>;
12
- nbformat: number;
13
- }
14
-
15
- /** Check if a file path is a notebook */
16
- export function isNotebookPath(filePath: string): boolean {
17
- return filePath.toLowerCase().endsWith(".ipynb");
18
- }
19
-
20
- function normalizeSource(source: unknown): string {
21
- if (typeof source === "string") return source;
22
- if (Array.isArray(source)) return source.join("");
23
- return "";
24
- }
25
-
26
- /** Parse a .ipynb JSON file into a Notebook structure */
27
- export function parseNotebook(content: string): Notebook {
28
- let raw: unknown;
29
- try {
30
- raw = JSON.parse(content);
31
- } catch {
32
- return { cells: [], nbformat: 4 };
33
- }
34
- if (typeof raw !== "object" || raw === null) return { cells: [], nbformat: 4 };
35
- const obj = raw as Record<string, unknown>;
36
- const nbformat = typeof obj.nbformat === "number" ? obj.nbformat : 4;
37
- const rawCells = Array.isArray(obj.cells) ? obj.cells : [];
38
- const cells: NotebookCell[] = rawCells
39
- .map((c: unknown, i: number): NotebookCell | null => {
40
- if (typeof c !== "object" || c === null) return null;
41
- const cell = c as Record<string, unknown>;
42
- const cellType = cell.cell_type;
43
- if (cellType !== "code" && cellType !== "markdown" && cellType !== "raw") return null;
44
- return {
45
- index: i,
46
- cellType,
47
- source: normalizeSource(cell.source),
48
- outputs: Array.isArray(cell.outputs) ? cell.outputs : undefined,
49
- metadata:
50
- cell.metadata && typeof cell.metadata === "object" && !Array.isArray(cell.metadata)
51
- ? (cell.metadata as Record<string, unknown>)
52
- : undefined,
53
- };
54
- })
55
- .filter((c: NotebookCell | null): c is NotebookCell => c !== null);
56
- const metadata =
57
- obj.metadata && typeof obj.metadata === "object" && !Array.isArray(obj.metadata)
58
- ? (obj.metadata as Record<string, unknown>)
59
- : undefined;
60
- return { cells, metadata, nbformat };
61
- }
62
-
63
- /** Get a specific cell by index */
64
- export function getCell(notebook: Notebook, index: number): NotebookCell | undefined {
65
- return notebook.cells.find((c) => c.index === index);
66
- }
67
-
68
- /** Update a cell's source content, returning a new Notebook */
69
- export function updateCell(notebook: Notebook, index: number, source: string): Notebook {
70
- const cells = notebook.cells.map((c) => (c.index === index ? { ...c, source } : c));
71
- return { ...notebook, cells };
72
- }
73
-
74
- /** Serialize a Notebook back to .ipynb JSON string */
75
- export function serializeNotebook(notebook: Notebook): string {
76
- const raw = {
77
- nbformat: notebook.nbformat,
78
- nbformat_minor: 5,
79
- metadata: notebook.metadata ?? {},
80
- cells: notebook.cells.map((c) => ({
81
- cell_type: c.cellType,
82
- source: c.source,
83
- metadata: c.metadata ?? {},
84
- ...(c.cellType === "code" ? { outputs: c.outputs ?? [], execution_count: null } : {}),
85
- })),
86
- };
87
- return JSON.stringify(raw, null, 2) + "\n";
88
- }
@@ -1,7 +0,0 @@
1
- /**
2
- * Orphan sentinel — DEPRECATED, replaced by parent-guard.ts.
3
- *
4
- * Keeping this file as a no-op placeholder until all references are removed.
5
- * Workers now self-monitor via startParentGuard() instead of relying on
6
- * an external sentinel process.
7
- */