@astralyn/pi 0.84.0 → 0.84.2

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 (176) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/cli/args.js +3 -3
  3. package/dist/cli/args.js.map +1 -1
  4. package/dist/cli/auth-check.d.ts +19 -0
  5. package/dist/cli/auth-check.d.ts.map +1 -0
  6. package/dist/cli/auth-check.js +50 -0
  7. package/dist/cli/auth-check.js.map +1 -0
  8. package/dist/cli/auth-command.d.ts +24 -0
  9. package/dist/cli/auth-command.d.ts.map +1 -0
  10. package/dist/cli/auth-command.js +104 -0
  11. package/dist/cli/auth-command.js.map +1 -0
  12. package/dist/cli/credential-print.d.ts +4 -14
  13. package/dist/cli/credential-print.d.ts.map +1 -1
  14. package/dist/cli/credential-print.js +38 -79
  15. package/dist/cli/credential-print.js.map +1 -1
  16. package/dist/core/auth-storage.d.ts +10 -0
  17. package/dist/core/auth-storage.d.ts.map +1 -1
  18. package/dist/core/auth-storage.js +76 -1
  19. package/dist/core/auth-storage.js.map +1 -1
  20. package/dist/core/extensions/types.d.ts +5 -0
  21. package/dist/core/extensions/types.d.ts.map +1 -1
  22. package/dist/core/extensions/types.js.map +1 -1
  23. package/dist/core/keybindings.d.ts +8 -0
  24. package/dist/core/keybindings.d.ts.map +1 -1
  25. package/dist/core/model-resolver.d.ts.map +1 -1
  26. package/dist/core/model-resolver.js +1 -0
  27. package/dist/core/model-resolver.js.map +1 -1
  28. package/dist/core/model-runtime.d.ts +2 -0
  29. package/dist/core/model-runtime.d.ts.map +1 -1
  30. package/dist/core/model-runtime.js +3 -1
  31. package/dist/core/model-runtime.js.map +1 -1
  32. package/dist/core/tools/bash.d.ts +4 -0
  33. package/dist/core/tools/bash.d.ts.map +1 -1
  34. package/dist/core/tools/bash.js +6 -4
  35. package/dist/core/tools/bash.js.map +1 -1
  36. package/dist/core/tools/edit.d.ts +4 -0
  37. package/dist/core/tools/edit.d.ts.map +1 -1
  38. package/dist/core/tools/edit.js +11 -7
  39. package/dist/core/tools/edit.js.map +1 -1
  40. package/dist/core/tools/find.d.ts +4 -0
  41. package/dist/core/tools/find.d.ts.map +1 -1
  42. package/dist/core/tools/find.js +5 -1
  43. package/dist/core/tools/find.js.map +1 -1
  44. package/dist/core/tools/grep.d.ts +4 -0
  45. package/dist/core/tools/grep.d.ts.map +1 -1
  46. package/dist/core/tools/grep.js +5 -1
  47. package/dist/core/tools/grep.js.map +1 -1
  48. package/dist/core/tools/ls.d.ts +4 -0
  49. package/dist/core/tools/ls.d.ts.map +1 -1
  50. package/dist/core/tools/ls.js +5 -1
  51. package/dist/core/tools/ls.js.map +1 -1
  52. package/dist/core/tools/read.d.ts +4 -0
  53. package/dist/core/tools/read.d.ts.map +1 -1
  54. package/dist/core/tools/read.js +6 -2
  55. package/dist/core/tools/read.js.map +1 -1
  56. package/dist/core/tools/write.d.ts +4 -0
  57. package/dist/core/tools/write.d.ts.map +1 -1
  58. package/dist/core/tools/write.js +6 -2
  59. package/dist/core/tools/write.js.map +1 -1
  60. package/dist/extensions/index.d.ts.map +1 -1
  61. package/dist/extensions/index.js +0 -4
  62. package/dist/extensions/index.js.map +1 -1
  63. package/dist/extensions/todo/schema.d.ts +1 -1
  64. package/dist/extensions/todo/state.js +5 -5
  65. package/dist/extensions/todo/state.js.map +1 -1
  66. package/dist/main.d.ts.map +1 -1
  67. package/dist/main.js +57 -21
  68. package/dist/main.js.map +1 -1
  69. package/dist/modes/interactive/interactive-mode.d.ts +3 -4
  70. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  71. package/dist/modes/interactive/interactive-mode.js +48 -41
  72. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  73. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  74. package/dist/modes/interactive/theme/theme.js +11 -3
  75. package/dist/modes/interactive/theme/theme.js.map +1 -1
  76. package/dist/server/create-harness.d.ts +26 -0
  77. package/dist/server/create-harness.d.ts.map +1 -0
  78. package/dist/server/create-harness.js +104 -0
  79. package/dist/server/create-harness.js.map +1 -0
  80. package/dist/utils/coalescing-terminal.d.ts +19 -4
  81. package/dist/utils/coalescing-terminal.d.ts.map +1 -1
  82. package/dist/utils/coalescing-terminal.js +33 -5
  83. package/dist/utils/coalescing-terminal.js.map +1 -1
  84. package/docs/bundled/README.md +0 -2
  85. package/docs/docs.json +0 -8
  86. package/docs/extensions.md +3 -2
  87. package/docs/keybindings.md +9 -5
  88. package/docs/providers.md +8 -1
  89. package/docs/usage.md +0 -1
  90. package/examples/extensions/README.md +2 -0
  91. package/examples/extensions/custom-provider-anthropic/package-lock.json +5 -5
  92. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  93. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  94. package/examples/extensions/gondolin/package-lock.json +2 -2
  95. package/examples/extensions/gondolin/package.json +1 -1
  96. package/examples/extensions/plan-mode/README.md +66 -0
  97. package/examples/extensions/plan-mode/index.ts +390 -0
  98. package/examples/extensions/plan-mode/utils.ts +168 -0
  99. package/examples/extensions/sandbox/package-lock.json +3 -3
  100. package/examples/extensions/sandbox/package.json +1 -1
  101. package/examples/extensions/subagent/README.md +175 -0
  102. package/examples/extensions/subagent/agents/planner.md +37 -0
  103. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  104. package/examples/extensions/subagent/agents/scout.md +50 -0
  105. package/examples/extensions/subagent/agents/worker.md +24 -0
  106. package/examples/extensions/subagent/agents.ts +126 -0
  107. package/examples/extensions/subagent/index.ts +1009 -0
  108. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  109. package/examples/extensions/subagent/prompts/implement.md +10 -0
  110. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  111. package/examples/extensions/with-deps/package-lock.json +4 -4
  112. package/examples/extensions/with-deps/package.json +1 -1
  113. package/npm-shrinkwrap.json +26 -26
  114. package/package.json +6 -6
  115. package/dist/extensions/biu/index.d.ts +0 -19
  116. package/dist/extensions/biu/index.d.ts.map +0 -1
  117. package/dist/extensions/biu/index.js +0 -350
  118. package/dist/extensions/biu/index.js.map +0 -1
  119. package/dist/extensions/biu/prompts.d.ts +0 -26
  120. package/dist/extensions/biu/prompts.d.ts.map +0 -1
  121. package/dist/extensions/biu/prompts.js +0 -260
  122. package/dist/extensions/biu/prompts.js.map +0 -1
  123. package/dist/extensions/biu/workspace.d.ts +0 -122
  124. package/dist/extensions/biu/workspace.d.ts.map +0 -1
  125. package/dist/extensions/biu/workspace.js +0 -404
  126. package/dist/extensions/biu/workspace.js.map +0 -1
  127. package/dist/extensions/rewind/anchor.d.ts +0 -29
  128. package/dist/extensions/rewind/anchor.d.ts.map +0 -1
  129. package/dist/extensions/rewind/anchor.js +0 -35
  130. package/dist/extensions/rewind/anchor.js.map +0 -1
  131. package/dist/extensions/rewind/config.d.ts +0 -16
  132. package/dist/extensions/rewind/config.d.ts.map +0 -1
  133. package/dist/extensions/rewind/config.js +0 -69
  134. package/dist/extensions/rewind/config.js.map +0 -1
  135. package/dist/extensions/rewind/engine.d.ts +0 -93
  136. package/dist/extensions/rewind/engine.d.ts.map +0 -1
  137. package/dist/extensions/rewind/engine.js +0 -778
  138. package/dist/extensions/rewind/engine.js.map +0 -1
  139. package/dist/extensions/rewind/gc.d.ts +0 -35
  140. package/dist/extensions/rewind/gc.d.ts.map +0 -1
  141. package/dist/extensions/rewind/gc.js +0 -169
  142. package/dist/extensions/rewind/gc.js.map +0 -1
  143. package/dist/extensions/rewind/index.d.ts +0 -3
  144. package/dist/extensions/rewind/index.d.ts.map +0 -1
  145. package/dist/extensions/rewind/index.js +0 -287
  146. package/dist/extensions/rewind/index.js.map +0 -1
  147. package/dist/extensions/rewind/menu.d.ts +0 -13
  148. package/dist/extensions/rewind/menu.d.ts.map +0 -1
  149. package/dist/extensions/rewind/menu.js +0 -123
  150. package/dist/extensions/rewind/menu.js.map +0 -1
  151. package/dist/extensions/rewind/paths.d.ts +0 -4
  152. package/dist/extensions/rewind/paths.d.ts.map +0 -1
  153. package/dist/extensions/rewind/paths.js +0 -15
  154. package/dist/extensions/rewind/paths.js.map +0 -1
  155. package/dist/extensions/rewind/restore.d.ts +0 -54
  156. package/dist/extensions/rewind/restore.d.ts.map +0 -1
  157. package/dist/extensions/rewind/restore.js +0 -94
  158. package/dist/extensions/rewind/restore.js.map +0 -1
  159. package/dist/extensions/rewind/snapshot.d.ts +0 -45
  160. package/dist/extensions/rewind/snapshot.d.ts.map +0 -1
  161. package/dist/extensions/rewind/snapshot.js +0 -21
  162. package/dist/extensions/rewind/snapshot.js.map +0 -1
  163. package/dist/extensions/rewind/storage.d.ts +0 -12
  164. package/dist/extensions/rewind/storage.d.ts.map +0 -1
  165. package/dist/extensions/rewind/storage.js +0 -33
  166. package/dist/extensions/rewind/storage.js.map +0 -1
  167. package/dist/extensions/rewind/text.d.ts +0 -7
  168. package/dist/extensions/rewind/text.d.ts.map +0 -1
  169. package/dist/extensions/rewind/text.js +0 -19
  170. package/dist/extensions/rewind/text.js.map +0 -1
  171. package/dist/extensions/rewind/tool-path.d.ts +0 -3
  172. package/dist/extensions/rewind/tool-path.d.ts.map +0 -1
  173. package/dist/extensions/rewind/tool-path.js +0 -9
  174. package/dist/extensions/rewind/tool-path.js.map +0 -1
  175. package/docs/bundled/extensions/biu.md +0 -63
  176. package/docs/bundled/extensions/rewind.md +0 -86
@@ -1,778 +0,0 @@
1
- /**
2
- * engine.ts — file-history backup engine for the rewind extension.
3
- *
4
- * Ported from Claude Code 2.1.88's src/utils/fileHistory.ts, adapted from its
5
- * React state-updater to module-level per-session maps. The core idea (and the
6
- * reason this exists): we back up ONLY the files Pi's edit/write tools are about
7
- * to modify — one copyFile before the edit — instead of snapshotting the whole
8
- * work tree. Cost is proportional to "how many files Pi changed", not project
9
- * size, so it never blocks the session-lifecycle critical path and storage stays
10
- * tiny.
11
- *
12
- * Per turn:
13
- * - beginTurn() (before_agent_start): open a working frame and re-record every
14
- * already-tracked file at its current (turn-start) state, reusing the latest
15
- * backup when unchanged (worktree lastSeen → mtime/size/content), creating a
16
- * new version when not. lastSeen skips backup-blob IO when the worktree has
17
- * not moved since the last confirm; content compare streams in 64 KiB chunks.
18
- * - trackEdit() (tool_call edit|write, before the write): back up a *newly*
19
- * edited file at its pre-edit state into the working frame (null marker when
20
- * the target does not exist yet, so rewind deletes the created file).
21
- * - endTurn() (agent_settled): if anything changed AND the caller resolved a
22
- * turn anchor, stamp + return the frame to persist; else discard it (an
23
- * unanchored frame could never be matched by /tree and would exist only in
24
- * memory). agent_settled (not agent_end) so auto-retry / overflow
25
- * compact-retry / queued follow-ups share one logical turn.
26
- *
27
- * Rewind = applySnapshot(): restore every tracked file to the version recorded in
28
- * the target frame (copy back / delete for null), touching only files that differ.
29
- *
30
- * Backups live at <rewindBackupsDir(sessionId)>/<sha256(relpath)[:16]>@v<n>.
31
- */
32
- import { createHash } from "node:crypto";
33
- import { chmodSync, copyFileSync, mkdirSync, statSync } from "node:fs";
34
- import { chmod, copyFile, link, mkdir, open, readFile, stat, unlink } from "node:fs/promises";
35
- import { dirname, isAbsolute, join, relative } from "node:path";
36
- import { backupsDir } from "./storage.js";
37
- /** Cap on retained snapshots per session (matches CC's MAX_SNAPSHOTS). */
38
- const MAX_SNAPSHOTS = 100;
39
- /** Avoid reading very large files solely to compare backup content. */
40
- const MAX_CONTENT_BYTES = 25 * 1024 * 1024;
41
- /** Cap concurrent backup/compare/restore IO so long tracked sets don't thrash handles. */
42
- const IO_CONCURRENCY = 16;
43
- /** Cap bytes loaded for coarse restore line stats (confirm dialog only). */
44
- const MAX_DIFF_BYTES = 1_048_576;
45
- /** Streaming compare chunk size (avoids buffering whole files up to MAX_CONTENT_BYTES). */
46
- const COMPARE_CHUNK_BYTES = 64 * 1024;
47
- function seenFromStats(st) {
48
- return { mtimeMs: st.mtimeMs, size: st.size, mode: st.mode };
49
- }
50
- function sameSeen(a, b) {
51
- return a.mtimeMs === b.mtimeMs && a.size === b.size && a.mode === b.mode;
52
- }
53
- // ---- per-session state ----------------------------------------------------
54
- const states = new Map();
55
- const cwds = new Map();
56
- function freshState() {
57
- return {
58
- snapshots: [],
59
- trackedFiles: new Set(),
60
- latestByTracking: new Map(),
61
- lastSeen: new Map(),
62
- pending: null,
63
- dirty: false,
64
- seq: 0,
65
- };
66
- }
67
- /** Run async work over items with a fixed concurrency cap. Preserves result order. */
68
- async function mapPool(items, concurrency, fn) {
69
- if (items.length === 0)
70
- return [];
71
- const results = new Array(items.length);
72
- let next = 0;
73
- async function worker() {
74
- for (;;) {
75
- const i = next++;
76
- if (i >= items.length)
77
- return;
78
- results[i] = await fn(items[i]);
79
- }
80
- }
81
- const n = Math.min(concurrency, items.length);
82
- await Promise.all(Array.from({ length: n }, () => worker()));
83
- return results;
84
- }
85
- /** Rebuild latestByTracking from finalized frames (oldest → newest, last write wins). */
86
- function rebuildLatestIndex(snapshots) {
87
- const latest = new Map();
88
- for (const snap of snapshots) {
89
- for (const [tracking, backup] of Object.entries(snap.trackedFileBackups)) {
90
- latest.set(tracking, backup);
91
- }
92
- }
93
- return latest;
94
- }
95
- function getState(sid) {
96
- let s = states.get(sid);
97
- if (!s) {
98
- s = freshState();
99
- states.set(sid, s);
100
- }
101
- return s;
102
- }
103
- /** Bind a session id to its cwd (for relative-path keying). Call at session_start. */
104
- export function registerSession(sid, cwd) {
105
- cwds.set(sid, cwd);
106
- getState(sid);
107
- }
108
- export function disposeSession(sid) {
109
- states.delete(sid);
110
- cwds.delete(sid);
111
- }
112
- export function getSnapshots(sid) {
113
- return states.get(sid)?.snapshots ?? [];
114
- }
115
- function cwdFor(sid) {
116
- return cwds.get(sid) ?? process.cwd();
117
- }
118
- // ---- path helpers ---------------------------------------------------------
119
- /** Use the cwd-relative path as the tracking key when inside cwd (shorter, portable). */
120
- function shorten(absPath, cwd) {
121
- if (!isAbsolute(absPath))
122
- return absPath;
123
- if (absPath === cwd)
124
- return absPath;
125
- const rel = relative(cwd, absPath);
126
- if (!rel || rel.startsWith("..") || isAbsolute(rel))
127
- return absPath;
128
- return rel;
129
- }
130
- function expand(tracking, cwd) {
131
- return isAbsolute(tracking) ? tracking : join(cwd, tracking);
132
- }
133
- function backupName(tracking, version) {
134
- const h = createHash("sha256").update(tracking).digest("hex").slice(0, 16);
135
- return `${h}@v${version}`;
136
- }
137
- function backupPathFor(sid, name) {
138
- return join(backupsDir(sid), name);
139
- }
140
- function latestBackupOf(state, tracking) {
141
- return state.latestByTracking.get(tracking);
142
- }
143
- /** First-ever recorded backup for a file (used when rewinding before it was tracked). */
144
- function firstBackupName(state, tracking) {
145
- for (const snap of state.snapshots) {
146
- const b = snap.trackedFileBackups[tracking];
147
- if (b && b.version === 1)
148
- return b.backupName;
149
- }
150
- return undefined;
151
- }
152
- /** Resolve the backup blob name for `tracking` at `target` (null = did not exist). */
153
- function backupNameForTarget(state, target, tracking) {
154
- const tb = target.trackedFileBackups[tracking];
155
- if (tb)
156
- return tb.backupName;
157
- return firstBackupName(state, tracking);
158
- }
159
- // ---- change detection (ported fileHistory.ts compareStatsAndContent;
160
- // content path streams via filesEqualChunked instead of full-buffer reads)
161
- function isENOENT(e) {
162
- return !!e && typeof e === "object" && e.code === "ENOENT";
163
- }
164
- async function statOrNull(p) {
165
- try {
166
- return await stat(p);
167
- }
168
- catch (e) {
169
- if (isENOENT(e))
170
- return null;
171
- throw e;
172
- }
173
- }
174
- /**
175
- * Raw-byte equality without buffering whole files. A utf-8 decode maps invalid
176
- * sequences to U+FFFD, which can make two DIFFERENT binary files compare equal —
177
- * and then rewind would silently skip both the re-backup and the restore.
178
- */
179
- async function filesEqualChunked(aPath, bPath, size) {
180
- if (size === 0)
181
- return true;
182
- const [aHandle, bHandle] = await Promise.all([open(aPath, "r"), open(bPath, "r")]);
183
- try {
184
- const aBuf = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
185
- const bBuf = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
186
- let remaining = size;
187
- while (remaining > 0) {
188
- const want = Math.min(COMPARE_CHUNK_BYTES, remaining);
189
- const [aRead, bRead] = await Promise.all([
190
- aHandle.read(aBuf, 0, want, null),
191
- bHandle.read(bBuf, 0, want, null),
192
- ]);
193
- if (aRead.bytesRead !== bRead.bytesRead)
194
- return false;
195
- if (aRead.bytesRead === 0)
196
- return remaining === 0;
197
- if (!aBuf.subarray(0, aRead.bytesRead).equals(bBuf.subarray(0, bRead.bytesRead)))
198
- return false;
199
- remaining -= aRead.bytesRead;
200
- }
201
- return true;
202
- }
203
- finally {
204
- await Promise.allSettled([aHandle.close(), bHandle.close()]);
205
- }
206
- }
207
- /** True when the on-disk file differs from its backup blob. */
208
- async function originChanged(sid, filePath, name, opts) {
209
- const backupPath = backupPathFor(sid, name);
210
- const orig = opts?.hint ?? (await statOrNull(filePath).catch(() => null));
211
- const back = await statOrNull(backupPath).catch(() => null);
212
- // One exists, one missing -> changed.
213
- if ((orig === null) !== (back === null))
214
- return true;
215
- if (orig === null || back === null)
216
- return false;
217
- if (orig.mode !== back.mode || orig.size !== back.size)
218
- return true;
219
- // Original untouched since the backup was written -> unchanged (skip content
220
- // read). Trusted only on the beginTurn path (worst case there: one redundant
221
- // version). Restore-side change detection must not be fooled by a same-size
222
- // content swap that carries an older mtime (archive extraction, touch -d).
223
- if (opts?.trustMtime && orig.mtimeMs < back.mtimeMs)
224
- return false;
225
- // Memory guard: don't load huge files just to byte-compare; assume changed and
226
- // let createBackup stream a new version instead (copyFile never buffers whole files).
227
- if (orig.size > MAX_CONTENT_BYTES)
228
- return true;
229
- try {
230
- return !(await filesEqualChunked(filePath, backupPath, orig.size));
231
- }
232
- catch {
233
- return true;
234
- }
235
- }
236
- // ---- backup / restore IO --------------------------------------------------
237
- /** Copy the file's current contents into a backup blob; null backup when absent. */
238
- async function createBackup(sid, filePath, tracking, version, known) {
239
- let src;
240
- if (known === null)
241
- return { backupName: null, version };
242
- if (known) {
243
- src = known;
244
- }
245
- else {
246
- try {
247
- src = await stat(filePath);
248
- }
249
- catch (e) {
250
- if (isENOENT(e))
251
- return { backupName: null, version };
252
- throw e;
253
- }
254
- }
255
- const name = backupName(tracking, version);
256
- const dest = backupPathFor(sid, name);
257
- try {
258
- await copyFile(filePath, dest);
259
- }
260
- catch (e) {
261
- if (!isENOENT(e))
262
- throw e;
263
- await mkdir(dirname(dest), { recursive: true });
264
- await copyFile(filePath, dest);
265
- }
266
- try {
267
- await chmod(dest, src.mode);
268
- }
269
- catch {
270
- // best-effort (e.g. Windows); content is what matters
271
- }
272
- return { backupName: name, version };
273
- }
274
- /**
275
- * Synchronous backup, used on the edit critical path (tool_call). Doing the copy
276
- * synchronously guarantees it completes before the hook returns control to the
277
- * host — so the backup always captures the file's PRE-edit contents, regardless
278
- * of whether the host awaits the hook before running the tool.
279
- *
280
- * `known` reuses a pre-stat from the caller (`null` = known ENOENT) so trackEdit
281
- * can record lastSeen without a second statSync on the critical path.
282
- */
283
- function createBackupSync(sid, filePath, tracking, version, known) {
284
- let src;
285
- if (known === null)
286
- return { backupName: null, version };
287
- if (known) {
288
- src = known;
289
- }
290
- else {
291
- try {
292
- src = statSync(filePath);
293
- }
294
- catch (e) {
295
- if (isENOENT(e))
296
- return { backupName: null, version };
297
- throw e;
298
- }
299
- }
300
- const name = backupName(tracking, version);
301
- const dest = backupPathFor(sid, name);
302
- try {
303
- copyFileSync(filePath, dest);
304
- }
305
- catch (e) {
306
- if (!isENOENT(e))
307
- throw e;
308
- mkdirSync(dirname(dest), { recursive: true });
309
- copyFileSync(filePath, dest);
310
- }
311
- try {
312
- chmodSync(dest, src.mode);
313
- }
314
- catch {
315
- // best-effort
316
- }
317
- return { backupName: name, version };
318
- }
319
- async function restoreBackup(sid, filePath, name) {
320
- const backupPath = backupPathFor(sid, name);
321
- let back;
322
- try {
323
- back = await stat(backupPath);
324
- }
325
- catch {
326
- return; // backup vanished; leave the file untouched
327
- }
328
- try {
329
- await copyFile(backupPath, filePath);
330
- }
331
- catch (e) {
332
- if (!isENOENT(e))
333
- throw e;
334
- await mkdir(dirname(filePath), { recursive: true });
335
- await copyFile(backupPath, filePath);
336
- }
337
- try {
338
- await chmod(filePath, back.mode);
339
- }
340
- catch {
341
- // best-effort
342
- }
343
- }
344
- // ---- per-turn lifecycle ---------------------------------------------------
345
- /**
346
- * Open the working frame for a turn and re-record every tracked file at its
347
- * current state (reuse latest backup when unchanged, new version when changed).
348
- * Sets state.dirty when any file produced a new version.
349
- */
350
- export async function beginTurn(sid) {
351
- const state = getState(sid);
352
- const cwd = cwdFor(sid);
353
- const backups = {};
354
- let dirty = false;
355
- await mapPool(Array.from(state.trackedFiles), IO_CONCURRENCY, async (tracking) => {
356
- try {
357
- const filePath = expand(tracking, cwd);
358
- const latest = latestBackupOf(state, tracking);
359
- const nextVersion = latest ? latest.version + 1 : 1;
360
- const st = await statOrNull(filePath);
361
- if (!st) {
362
- // Already recorded absent in the latest frame -> reuse it. Allocating a
363
- // fresh null version every turn would pin dirty=true forever once a
364
- // tracked file is deleted, defeating the "skip unchanged turns" check
365
- // and flushing real checkpoints out of the capped ring.
366
- state.lastSeen.delete(tracking);
367
- if (latest && latest.backupName === null) {
368
- backups[tracking] = latest;
369
- return;
370
- }
371
- const absent = { backupName: null, version: nextVersion };
372
- backups[tracking] = absent;
373
- state.latestByTracking.set(tracking, absent);
374
- dirty = true;
375
- return;
376
- }
377
- // Worktree identity unchanged since last backup/confirm → reuse without
378
- // opening the backup blob (still paid one worktree stat above).
379
- const seen = state.lastSeen.get(tracking);
380
- if (latest && latest.backupName !== null && seen && sameSeen(seen, st)) {
381
- backups[tracking] = latest;
382
- return;
383
- }
384
- if (latest &&
385
- latest.backupName !== null &&
386
- !(await originChanged(sid, filePath, latest.backupName, { hint: st, trustMtime: true }))) {
387
- backups[tracking] = latest; // unchanged -> reuse
388
- state.lastSeen.set(tracking, seenFromStats(st));
389
- return;
390
- }
391
- const created = await createBackup(sid, filePath, tracking, nextVersion, st);
392
- backups[tracking] = created;
393
- state.latestByTracking.set(tracking, created);
394
- // Record the pre-backup worktree identity (copyFile does not change it).
395
- state.lastSeen.set(tracking, seenFromStats(st));
396
- dirty = true;
397
- }
398
- catch {
399
- // skip this file; never break the turn
400
- }
401
- });
402
- state.pending = { v: 1, userEntryId: "", turnId: "", prompt: "", trackedFileBackups: backups, timestamp: "" };
403
- state.dirty = dirty;
404
- }
405
- /**
406
- * Back up a file about to be edited/written, if not already captured this turn.
407
- * Call from the tool_call hook BEFORE the edit lands. ENOENT target -> null
408
- * marker (rewind deletes the created file). Synchronous so the backup is on disk
409
- * before the hook returns (see createBackupSync).
410
- */
411
- /**
412
- * Reuse an existing tracking key that differs only by case (Windows paths are
413
- * case-insensitive): two keys for one physical file would race each other in
414
- * applySnapshot's concurrent restore.
415
- */
416
- function canonicalTracking(state, tracking) {
417
- if (process.platform !== "win32" || state.trackedFiles.has(tracking))
418
- return tracking;
419
- const lower = tracking.toLowerCase();
420
- for (const existing of state.trackedFiles) {
421
- if (existing.toLowerCase() === lower)
422
- return existing;
423
- }
424
- return tracking;
425
- }
426
- export function trackEdit(sid, absPath) {
427
- const state = getState(sid);
428
- const cwd = cwdFor(sid);
429
- const tracking = canonicalTracking(state, shorten(absPath, cwd));
430
- if (!state.pending) {
431
- state.pending = { v: 1, userEntryId: "", turnId: "", prompt: "", trackedFileBackups: {}, timestamp: "" };
432
- }
433
- if (state.pending.trackedFileBackups[tracking])
434
- return; // already captured this turn
435
- const latest = latestBackupOf(state, tracking);
436
- const version = latest ? latest.version + 1 : 1;
437
- const filePath = expand(tracking, cwd);
438
- // One pre-edit stat: drives the backup and lastSeen (null = new-file marker).
439
- let preEdit = null;
440
- try {
441
- preEdit = statSync(filePath);
442
- }
443
- catch (e) {
444
- if (!isENOENT(e))
445
- throw e;
446
- }
447
- const backup = createBackupSync(sid, filePath, tracking, version, preEdit);
448
- state.pending.trackedFileBackups[tracking] = backup;
449
- state.trackedFiles.add(tracking);
450
- state.latestByTracking.set(tracking, backup);
451
- if (preEdit)
452
- state.lastSeen.set(tracking, seenFromStats(preEdit));
453
- else
454
- state.lastSeen.delete(tracking);
455
- state.dirty = true;
456
- }
457
- /**
458
- * Finalize the turn. Returns the snapshot to persist (caller appendEntry's it),
459
- * or null when nothing changed this turn. Pushes finalized frames into the
460
- * in-memory ring (capped at MAX_SNAPSHOTS).
461
- */
462
- export function endTurn(sid, userEntryId, turnId, prompt, timestamp, maxSnapshots = MAX_SNAPSHOTS) {
463
- const state = getState(sid);
464
- const pending = state.pending;
465
- state.pending = null;
466
- if (!pending || !state.dirty) {
467
- state.dirty = false;
468
- return null;
469
- }
470
- state.dirty = false;
471
- // No resolvable anchor (e.g. a custom-triggered run that appended no user
472
- // entry): discard the frame. Pushing it into the ring without persisting it
473
- // would diverge memory from the JSONL, and /tree could never match it anyway.
474
- if (!userEntryId)
475
- return null;
476
- const frame = { ...pending, userEntryId, turnId, prompt, timestamp };
477
- state.snapshots.push(frame);
478
- if (state.snapshots.length > maxSnapshots) {
479
- const dropped = state.snapshots.slice(0, state.snapshots.length - maxSnapshots);
480
- state.snapshots = state.snapshots.slice(-maxSnapshots);
481
- void pruneDroppedBlobs(sid, dropped, state.snapshots);
482
- }
483
- state.seq++;
484
- return frame;
485
- }
486
- /**
487
- * Best-effort deletion of backup blobs referenced ONLY by frames dropped from
488
- * the capped ring. Backups are reused across frames (an unchanged file keeps
489
- * pointing at the same version), so a blob is unlinked only when no retained
490
- * frame still references it. Without this, the cap trims the in-memory index
491
- * while the blob files accumulate for the whole session — gc.ts only reclaims
492
- * whole session directories.
493
- */
494
- async function pruneDroppedBlobs(sid, dropped, retained) {
495
- const live = new Set();
496
- for (const snap of retained) {
497
- for (const b of Object.values(snap.trackedFileBackups)) {
498
- if (b.backupName)
499
- live.add(b.backupName);
500
- }
501
- }
502
- const doomed = new Set();
503
- for (const snap of dropped) {
504
- for (const b of Object.values(snap.trackedFileBackups)) {
505
- if (b.backupName && !live.has(b.backupName))
506
- doomed.add(b.backupName);
507
- }
508
- }
509
- if (doomed.size === 0)
510
- return;
511
- await Promise.allSettled(Array.from(doomed, (name) => unlink(backupPathFor(sid, name))));
512
- }
513
- // ---- rewind ---------------------------------------------------------------
514
- /**
515
- * Absolute paths that restoring to `target` would change on disk (empty =
516
- * nothing to do). Same walk applySnapshot performs, without writing — the
517
- * caller can both count and preview the files from one pass. Concurrent with
518
- * IO_CONCURRENCY; result order matches trackedFiles insertion order.
519
- */
520
- export async function collectChanges(sid, target) {
521
- const state = getState(sid);
522
- const cwd = cwdFor(sid);
523
- const trackings = Array.from(state.trackedFiles);
524
- const results = await mapPool(trackings, IO_CONCURRENCY, async (tracking) => {
525
- try {
526
- const filePath = expand(tracking, cwd);
527
- const name = backupNameForTarget(state, target, tracking);
528
- if (name === undefined)
529
- return null;
530
- if (name === null) {
531
- return (await statOrNull(filePath)) ? filePath : null;
532
- }
533
- return (await originChanged(sid, filePath, name)) ? filePath : null;
534
- }
535
- catch {
536
- return null;
537
- }
538
- });
539
- return results.filter((p) => p !== null);
540
- }
541
- /**
542
- * Coarse +N/−M line stats for a restore confirm dialog. Only loads text for
543
- * paths already known to differ (from collectChanges). Oversized files still
544
- * appear in `paths` but contribute 0 to line totals. No extra dependency.
545
- */
546
- export async function collectChangeDiffStats(sid, target, changedPaths) {
547
- if (changedPaths.length === 0) {
548
- return { paths: [], insertions: 0, deletions: 0 };
549
- }
550
- const state = getState(sid);
551
- const cwd = cwdFor(sid);
552
- // Reverse map abs path → tracking key for the known changed set.
553
- const trackingByAbs = new Map();
554
- for (const tracking of state.trackedFiles) {
555
- trackingByAbs.set(expand(tracking, cwd), tracking);
556
- }
557
- const results = await mapPool([...changedPaths], IO_CONCURRENCY, async (filePath) => {
558
- try {
559
- const tracking = trackingByAbs.get(filePath);
560
- if (!tracking)
561
- return { insertions: 0, deletions: 0 };
562
- const name = backupNameForTarget(state, target, tracking);
563
- if (name === undefined)
564
- return { insertions: 0, deletions: 0 };
565
- return await coarseLineDelta(sid, filePath, name);
566
- }
567
- catch {
568
- return { insertions: 0, deletions: 0 };
569
- }
570
- });
571
- let insertions = 0;
572
- let deletions = 0;
573
- for (const r of results) {
574
- insertions += r.insertions;
575
- deletions += r.deletions;
576
- }
577
- return { paths: [...changedPaths], insertions, deletions };
578
- }
579
- /**
580
- * Bag-of-lines delta for current worktree → backup target (restore direction).
581
- * insertions = lines that appear after restore; deletions = lines removed.
582
- */
583
- async function coarseLineDelta(sid, filePath, backupName) {
584
- const currentText = await readTextCapped(filePath, MAX_DIFF_BYTES);
585
- if (backupName === null) {
586
- // Snapshot: file did not exist → restore deletes it.
587
- if (currentText === null)
588
- return { insertions: 0, deletions: 0 };
589
- return { insertions: 0, deletions: countLines(currentText) };
590
- }
591
- const backupText = await readTextCapped(backupPathFor(sid, backupName), MAX_DIFF_BYTES);
592
- if (currentText === null && backupText === null)
593
- return { insertions: 0, deletions: 0 };
594
- if (currentText === null)
595
- return { insertions: countLines(backupText ?? ""), deletions: 0 };
596
- if (backupText === null)
597
- return { insertions: 0, deletions: countLines(currentText) };
598
- return bagOfLinesDelta(currentText, backupText);
599
- }
600
- /** null = missing or oversize/unreadable (skip line contrib). */
601
- async function readTextCapped(filePath, maxBytes) {
602
- try {
603
- const st = await statOrNull(filePath);
604
- if (!st)
605
- return null;
606
- if (st.size > maxBytes)
607
- return null;
608
- const buf = await readFile(filePath);
609
- // Skip obvious binary (NUL in the first 8K).
610
- const sample = buf.subarray(0, Math.min(buf.length, 8192));
611
- if (sample.includes(0))
612
- return null;
613
- return buf.toString("utf8");
614
- }
615
- catch {
616
- return null;
617
- }
618
- }
619
- function countLines(text) {
620
- if (text.length === 0)
621
- return 0;
622
- let n = 1;
623
- for (let i = 0; i < text.length; i++) {
624
- if (text.charCodeAt(i) === 10 /* \n */)
625
- n++;
626
- }
627
- // Trailing newline does not add an extra empty "line" for display counts.
628
- if (text.endsWith("\n"))
629
- n--;
630
- return Math.max(n, 0);
631
- }
632
- /**
633
- * Multiset frequency delta: from → to.
634
- * deletions = lines over-represented in `from`; insertions = over-represented in `to`.
635
- */
636
- function bagOfLinesDelta(from, to) {
637
- const fromCounts = lineFrequency(from);
638
- const toCounts = lineFrequency(to);
639
- let insertions = 0;
640
- let deletions = 0;
641
- const keys = new Set([...fromCounts.keys(), ...toCounts.keys()]);
642
- for (const key of keys) {
643
- const a = fromCounts.get(key) ?? 0;
644
- const b = toCounts.get(key) ?? 0;
645
- if (b > a)
646
- insertions += b - a;
647
- else if (a > b)
648
- deletions += a - b;
649
- }
650
- return { insertions, deletions };
651
- }
652
- function lineFrequency(text) {
653
- const map = new Map();
654
- // Preserve empty file as zero lines; otherwise split including last empty.
655
- if (text.length === 0)
656
- return map;
657
- const lines = text.split("\n");
658
- if (lines.length > 0 && lines[lines.length - 1] === "")
659
- lines.pop();
660
- for (const line of lines) {
661
- map.set(line, (map.get(line) ?? 0) + 1);
662
- }
663
- return map;
664
- }
665
- /**
666
- * Restore the work tree to `target`. Returns the list of changed file paths.
667
- * Pass `onlyPaths` (from a prior collectChanges) to skip a second originChanged
668
- * pass and only touch those absolute paths.
669
- */
670
- export async function applySnapshot(sid, target, opts) {
671
- const state = getState(sid);
672
- const cwd = cwdFor(sid);
673
- const only = opts?.onlyPaths;
674
- const trackings = Array.from(state.trackedFiles).filter((tracking) => {
675
- if (!only)
676
- return true;
677
- return only.has(expand(tracking, cwd));
678
- });
679
- const results = await mapPool(trackings, IO_CONCURRENCY, async (tracking) => {
680
- try {
681
- const filePath = expand(tracking, cwd);
682
- const name = backupNameForTarget(state, target, tracking);
683
- if (name === undefined)
684
- return null; // can't resolve -> leave file alone
685
- if (name === null) {
686
- try {
687
- await unlink(filePath);
688
- // Worktree no longer matches any recorded identity.
689
- state.lastSeen.delete(tracking);
690
- return filePath;
691
- }
692
- catch (e) {
693
- if (!isENOENT(e))
694
- throw e;
695
- return null;
696
- }
697
- }
698
- // onlyPaths already proven different at confirm time — skip re-compare.
699
- if (only || (await originChanged(sid, filePath, name))) {
700
- await restoreBackup(sid, filePath, name);
701
- // Drop stale lastSeen: worktree now matches the target blob, not the
702
- // latest backup identity beginTurn compares against.
703
- state.lastSeen.delete(tracking);
704
- return filePath;
705
- }
706
- return null;
707
- }
708
- catch {
709
- return null;
710
- }
711
- });
712
- return results.filter((p) => p !== null);
713
- }
714
- // ---- persistence rebuild + resume migration -------------------------------
715
- /** The trailing window of `snapshots` retained under the cap (endTurn's ring). */
716
- export function capSnapshots(snapshots, maxSnapshots = MAX_SNAPSHOTS) {
717
- return snapshots.length > maxSnapshots ? snapshots.slice(-maxSnapshots) : snapshots;
718
- }
719
- /** Rebuild in-memory state from snapshots persisted in the session JSONL. */
720
- export function restoreStateFromSnapshots(sid, cwd, snapshots, maxSnapshots = MAX_SNAPSHOTS) {
721
- cwds.set(sid, cwd);
722
- // Apply the same cap on reload so a long session's JSONL can't reinflate the
723
- // in-memory ring past the limit. trackedFiles is rebuilt from the retained
724
- // frames only (older frames are unreachable for rewind anyway); blobs only
725
- // those frames referenced are pruned best-effort, mirroring endTurn.
726
- const retained = capSnapshots(snapshots, maxSnapshots);
727
- if (retained.length < snapshots.length) {
728
- void pruneDroppedBlobs(sid, snapshots.slice(0, snapshots.length - retained.length), retained);
729
- }
730
- const trackedFiles = new Set();
731
- for (const snap of retained) {
732
- for (const key of Object.keys(snap.trackedFileBackups))
733
- trackedFiles.add(key);
734
- }
735
- states.set(sid, {
736
- snapshots: [...retained],
737
- trackedFiles,
738
- latestByTracking: rebuildLatestIndex(retained),
739
- // lastSeen is process-local; after reload we re-establish it on the first
740
- // beginTurn via originChanged / createBackup.
741
- lastSeen: new Map(),
742
- pending: null,
743
- dirty: false,
744
- seq: retained.length,
745
- });
746
- }
747
- /**
748
- * Hard-link this session's backup blobs from a previous session's directory
749
- * (resume/fork carries the conversation + its snapshot index, but the blobs live
750
- * under the old session id). Falls back to copy. No-op when ids match.
751
- */
752
- export async function migrateBackupsFromSession(prevSid, sid, snapshots) {
753
- if (!prevSid || prevSid === sid)
754
- return;
755
- const destDir = backupsDir(sid);
756
- await mkdir(destDir, { recursive: true });
757
- await Promise.allSettled(snapshots.flatMap((snap) => Object.values(snap.trackedFileBackups)
758
- .filter((b) => b.backupName !== null)
759
- .map(async ({ backupName: name }) => {
760
- const from = join(backupsDir(prevSid), name);
761
- const to = join(destDir, name);
762
- try {
763
- await link(from, to);
764
- }
765
- catch (e) {
766
- const code = e.code;
767
- if (code === "EEXIST")
768
- return;
769
- try {
770
- await copyFile(from, to);
771
- }
772
- catch {
773
- // best-effort; a missing blob just means that version can't restore
774
- }
775
- }
776
- })));
777
- }
778
- //# sourceMappingURL=engine.js.map