@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.
- package/CHANGELOG.md +30 -1
- package/dist/cli/args.js +3 -3
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/auth-check.d.ts +19 -0
- package/dist/cli/auth-check.d.ts.map +1 -0
- package/dist/cli/auth-check.js +50 -0
- package/dist/cli/auth-check.js.map +1 -0
- package/dist/cli/auth-command.d.ts +24 -0
- package/dist/cli/auth-command.d.ts.map +1 -0
- package/dist/cli/auth-command.js +104 -0
- package/dist/cli/auth-command.js.map +1 -0
- package/dist/cli/credential-print.d.ts +4 -14
- package/dist/cli/credential-print.d.ts.map +1 -1
- package/dist/cli/credential-print.js +38 -79
- package/dist/cli/credential-print.js.map +1 -1
- package/dist/core/auth-storage.d.ts +10 -0
- package/dist/core/auth-storage.d.ts.map +1 -1
- package/dist/core/auth-storage.js +76 -1
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/extensions/types.d.ts +5 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/keybindings.d.ts +8 -0
- package/dist/core/keybindings.d.ts.map +1 -1
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +1 -0
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/model-runtime.d.ts +2 -0
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js +3 -1
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/tools/bash.d.ts +4 -0
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +6 -4
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit.d.ts +4 -0
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +11 -7
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts +4 -0
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +5 -1
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts +4 -0
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +5 -1
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/ls.d.ts +4 -0
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js +5 -1
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/read.d.ts +4 -0
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +6 -2
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/write.d.ts +4 -0
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js +6 -2
- package/dist/core/tools/write.js.map +1 -1
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +0 -4
- package/dist/extensions/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +1 -1
- package/dist/extensions/todo/state.js +5 -5
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +57 -21
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +3 -4
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +48 -41
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +11 -3
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/server/create-harness.d.ts +26 -0
- package/dist/server/create-harness.d.ts.map +1 -0
- package/dist/server/create-harness.js +104 -0
- package/dist/server/create-harness.js.map +1 -0
- package/dist/utils/coalescing-terminal.d.ts +19 -4
- package/dist/utils/coalescing-terminal.d.ts.map +1 -1
- package/dist/utils/coalescing-terminal.js +33 -5
- package/dist/utils/coalescing-terminal.js.map +1 -1
- package/docs/bundled/README.md +0 -2
- package/docs/docs.json +0 -8
- package/docs/extensions.md +3 -2
- package/docs/keybindings.md +9 -5
- package/docs/providers.md +8 -1
- package/docs/usage.md +0 -1
- package/examples/extensions/README.md +2 -0
- package/examples/extensions/custom-provider-anthropic/package-lock.json +5 -5
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/plan-mode/README.md +66 -0
- package/examples/extensions/plan-mode/index.ts +390 -0
- package/examples/extensions/plan-mode/utils.ts +168 -0
- package/examples/extensions/sandbox/package-lock.json +3 -3
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/subagent/README.md +175 -0
- package/examples/extensions/subagent/agents/planner.md +37 -0
- package/examples/extensions/subagent/agents/reviewer.md +35 -0
- package/examples/extensions/subagent/agents/scout.md +50 -0
- package/examples/extensions/subagent/agents/worker.md +24 -0
- package/examples/extensions/subagent/agents.ts +126 -0
- package/examples/extensions/subagent/index.ts +1009 -0
- package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/examples/extensions/subagent/prompts/implement.md +10 -0
- package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/examples/extensions/with-deps/package-lock.json +4 -4
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +26 -26
- package/package.json +6 -6
- package/dist/extensions/biu/index.d.ts +0 -19
- package/dist/extensions/biu/index.d.ts.map +0 -1
- package/dist/extensions/biu/index.js +0 -350
- package/dist/extensions/biu/index.js.map +0 -1
- package/dist/extensions/biu/prompts.d.ts +0 -26
- package/dist/extensions/biu/prompts.d.ts.map +0 -1
- package/dist/extensions/biu/prompts.js +0 -260
- package/dist/extensions/biu/prompts.js.map +0 -1
- package/dist/extensions/biu/workspace.d.ts +0 -122
- package/dist/extensions/biu/workspace.d.ts.map +0 -1
- package/dist/extensions/biu/workspace.js +0 -404
- package/dist/extensions/biu/workspace.js.map +0 -1
- package/dist/extensions/rewind/anchor.d.ts +0 -29
- package/dist/extensions/rewind/anchor.d.ts.map +0 -1
- package/dist/extensions/rewind/anchor.js +0 -35
- package/dist/extensions/rewind/anchor.js.map +0 -1
- package/dist/extensions/rewind/config.d.ts +0 -16
- package/dist/extensions/rewind/config.d.ts.map +0 -1
- package/dist/extensions/rewind/config.js +0 -69
- package/dist/extensions/rewind/config.js.map +0 -1
- package/dist/extensions/rewind/engine.d.ts +0 -93
- package/dist/extensions/rewind/engine.d.ts.map +0 -1
- package/dist/extensions/rewind/engine.js +0 -778
- package/dist/extensions/rewind/engine.js.map +0 -1
- package/dist/extensions/rewind/gc.d.ts +0 -35
- package/dist/extensions/rewind/gc.d.ts.map +0 -1
- package/dist/extensions/rewind/gc.js +0 -169
- package/dist/extensions/rewind/gc.js.map +0 -1
- package/dist/extensions/rewind/index.d.ts +0 -3
- package/dist/extensions/rewind/index.d.ts.map +0 -1
- package/dist/extensions/rewind/index.js +0 -287
- package/dist/extensions/rewind/index.js.map +0 -1
- package/dist/extensions/rewind/menu.d.ts +0 -13
- package/dist/extensions/rewind/menu.d.ts.map +0 -1
- package/dist/extensions/rewind/menu.js +0 -123
- package/dist/extensions/rewind/menu.js.map +0 -1
- package/dist/extensions/rewind/paths.d.ts +0 -4
- package/dist/extensions/rewind/paths.d.ts.map +0 -1
- package/dist/extensions/rewind/paths.js +0 -15
- package/dist/extensions/rewind/paths.js.map +0 -1
- package/dist/extensions/rewind/restore.d.ts +0 -54
- package/dist/extensions/rewind/restore.d.ts.map +0 -1
- package/dist/extensions/rewind/restore.js +0 -94
- package/dist/extensions/rewind/restore.js.map +0 -1
- package/dist/extensions/rewind/snapshot.d.ts +0 -45
- package/dist/extensions/rewind/snapshot.d.ts.map +0 -1
- package/dist/extensions/rewind/snapshot.js +0 -21
- package/dist/extensions/rewind/snapshot.js.map +0 -1
- package/dist/extensions/rewind/storage.d.ts +0 -12
- package/dist/extensions/rewind/storage.d.ts.map +0 -1
- package/dist/extensions/rewind/storage.js +0 -33
- package/dist/extensions/rewind/storage.js.map +0 -1
- package/dist/extensions/rewind/text.d.ts +0 -7
- package/dist/extensions/rewind/text.d.ts.map +0 -1
- package/dist/extensions/rewind/text.js +0 -19
- package/dist/extensions/rewind/text.js.map +0 -1
- package/dist/extensions/rewind/tool-path.d.ts +0 -3
- package/dist/extensions/rewind/tool-path.d.ts.map +0 -1
- package/dist/extensions/rewind/tool-path.js +0 -9
- package/dist/extensions/rewind/tool-path.js.map +0 -1
- package/docs/bundled/extensions/biu.md +0 -63
- 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
|