pi-rewind-hook 1.7.2 → 1.8.0
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 +26 -14
- package/README.md +127 -109
- package/banner.png +0 -0
- package/index.test.ts +602 -0
- package/index.ts +1205 -449
- package/install.js +37 -47
- package/package.json +8 -4
- package/test-support/pi-coding-agent-shim.ts +6 -0
- package/tsconfig.test.json +11 -0
package/index.ts
CHANGED
|
@@ -1,610 +1,1366 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rewind Extension -
|
|
2
|
+
* Rewind Extension - session-ledger based exact file restoration for pi branching
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Supports: restore files + conversation, files only, conversation only, undo last restore.
|
|
7
|
-
*
|
|
8
|
-
* Updated for pi-coding-agent v0.35.0+ (unified extensions system)
|
|
4
|
+
* Rewind v2 stores exact rewind metadata in hidden session custom entries and keeps
|
|
5
|
+
* snapshot commits reachable through a single repo-local store ref.
|
|
9
6
|
*/
|
|
10
7
|
|
|
11
|
-
import type
|
|
8
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
12
9
|
import { exec as execCb } from "child_process";
|
|
13
|
-
import { readFileSync } from "fs";
|
|
14
|
-
import { mkdtemp, rm } from "fs/promises";
|
|
15
|
-
import {
|
|
16
|
-
import { join } from "path";
|
|
10
|
+
import { existsSync, readFileSync, realpathSync } from "fs";
|
|
11
|
+
import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
17
14
|
import { promisify } from "util";
|
|
18
15
|
|
|
19
16
|
const execAsync = promisify(execCb);
|
|
20
17
|
|
|
21
|
-
const
|
|
22
|
-
const BEFORE_RESTORE_PREFIX = "before-restore-";
|
|
23
|
-
const MAX_CHECKPOINTS = 100;
|
|
18
|
+
const STORE_REF = "refs/pi-rewind/store";
|
|
24
19
|
const STATUS_KEY = "rewind";
|
|
25
|
-
const
|
|
20
|
+
const FORK_PREFERENCE_SOURCE_ALLOWLIST = new Set(["fork-from-first"]);
|
|
21
|
+
const LEGACY_ZERO_SHA = "0000000000000000000000000000000000000000";
|
|
22
|
+
const RETENTION_SWEEP_THRESHOLD = 50;
|
|
23
|
+
const RETENTION_VERSION = 2;
|
|
24
|
+
const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
26
25
|
|
|
27
26
|
type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
type GitExecResult = Awaited<ReturnType<ExecFn>>;
|
|
29
|
+
|
|
30
|
+
type BindingTuple = [entryId: string, snapshotIndex: number];
|
|
31
|
+
|
|
32
|
+
interface RewindSettings {
|
|
33
|
+
rewind?: {
|
|
34
|
+
silentCheckpoints?: boolean;
|
|
35
|
+
retention?: {
|
|
36
|
+
maxSnapshots?: number;
|
|
37
|
+
maxAgeDays?: number;
|
|
38
|
+
pinLabeledEntries?: boolean;
|
|
39
|
+
scanMode?: "ancestor-only" | "repo-sessions";
|
|
40
|
+
startupBudgetMs?: number;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type RewindRetentionSettings = NonNullable<NonNullable<RewindSettings["rewind"]>["retention"]>;
|
|
46
|
+
|
|
47
|
+
interface RewindTurnData {
|
|
48
|
+
v: 2;
|
|
49
|
+
snapshots: string[];
|
|
50
|
+
bindings: BindingTuple[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RewindOpData {
|
|
54
|
+
v: 2;
|
|
55
|
+
snapshots: string[];
|
|
56
|
+
bindings?: BindingTuple[];
|
|
57
|
+
current?: number;
|
|
58
|
+
undo?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ActivePromptCollector {
|
|
62
|
+
snapshots: string[];
|
|
63
|
+
bindings: BindingTuple[];
|
|
64
|
+
promptText?: string;
|
|
65
|
+
pendingUserCommitSha?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ExactState {
|
|
69
|
+
commitSha: string;
|
|
70
|
+
treeSha: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ActiveBranchState {
|
|
74
|
+
currentCommitSha?: string;
|
|
75
|
+
currentTreeSha?: string;
|
|
76
|
+
undoCommitSha?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface PendingResultingState {
|
|
80
|
+
currentCommitSha: string;
|
|
81
|
+
undoCommitSha?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ParsedLedgerReference {
|
|
85
|
+
commitSha: string;
|
|
86
|
+
entryId?: string;
|
|
87
|
+
timestamp: number;
|
|
88
|
+
kind: "binding" | "current" | "undo";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ParsedSessionLedger {
|
|
92
|
+
sessionFile: string;
|
|
93
|
+
sessionId?: string;
|
|
94
|
+
cwd?: string;
|
|
95
|
+
parentSession?: string;
|
|
96
|
+
entryToCommit: Map<string, string>;
|
|
97
|
+
labeledEntryIds: Set<string>;
|
|
98
|
+
references: ParsedLedgerReference[];
|
|
99
|
+
latestCurrentCommitSha?: string;
|
|
100
|
+
latestUndoCommitSha?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface SessionLikeMessageEntry {
|
|
104
|
+
type: "message";
|
|
105
|
+
id: string;
|
|
106
|
+
parentId: string | null;
|
|
107
|
+
timestamp: string;
|
|
108
|
+
message: {
|
|
109
|
+
role: string;
|
|
110
|
+
timestamp?: number;
|
|
111
|
+
content?: unknown;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface SessionLikeCustomEntry {
|
|
116
|
+
type: "custom";
|
|
117
|
+
id: string;
|
|
118
|
+
parentId: string | null;
|
|
119
|
+
timestamp: string;
|
|
120
|
+
customType: string;
|
|
121
|
+
data?: unknown;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface SessionLikeLabelEntry {
|
|
125
|
+
type: "label";
|
|
126
|
+
targetId: string;
|
|
127
|
+
label?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface SessionLikeBranchSummaryEntry {
|
|
131
|
+
type: "branch_summary";
|
|
132
|
+
id: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface SessionLikeGenericEntry {
|
|
136
|
+
type: string;
|
|
137
|
+
id?: string;
|
|
138
|
+
parentId?: string | null;
|
|
139
|
+
timestamp?: string;
|
|
140
|
+
message?: {
|
|
141
|
+
role?: string;
|
|
142
|
+
timestamp?: number;
|
|
143
|
+
content?: unknown;
|
|
144
|
+
};
|
|
145
|
+
customType?: string;
|
|
146
|
+
data?: unknown;
|
|
147
|
+
targetId?: string;
|
|
148
|
+
label?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type SessionLikeEntry =
|
|
152
|
+
| SessionLikeMessageEntry
|
|
153
|
+
| SessionLikeCustomEntry
|
|
154
|
+
| SessionLikeLabelEntry
|
|
155
|
+
| SessionLikeBranchSummaryEntry
|
|
156
|
+
| SessionLikeGenericEntry;
|
|
157
|
+
|
|
158
|
+
let cachedSettings: RewindSettings | null = null;
|
|
159
|
+
|
|
160
|
+
function getSettingsFilePath(): string {
|
|
161
|
+
return join(getAgentDir(), "settings.json");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getDefaultSessionsDir(): string {
|
|
165
|
+
return join(getAgentDir(), "sessions");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getSettings(): RewindSettings {
|
|
169
|
+
if (cachedSettings) {
|
|
170
|
+
return cachedSettings;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
cachedSettings = JSON.parse(readFileSync(getSettingsFilePath(), "utf-8")) as RewindSettings;
|
|
175
|
+
} catch {
|
|
176
|
+
// Invalid/missing settings must not disable rewind; fall back to defaults.
|
|
177
|
+
cachedSettings = {};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return cachedSettings;
|
|
181
|
+
}
|
|
30
182
|
|
|
31
183
|
function getSilentCheckpointsSetting(): boolean {
|
|
32
|
-
|
|
33
|
-
|
|
184
|
+
return getSettings().rewind?.silentCheckpoints === true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getRetentionSettings(): RewindRetentionSettings | undefined {
|
|
188
|
+
return getSettings().rewind?.retention;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getRetentionScanMode(): "ancestor-only" | "repo-sessions" {
|
|
192
|
+
return getRetentionSettings()?.scanMode === "repo-sessions" ? "repo-sessions" : "ancestor-only";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getStartupSweepBudgetMs(): number | undefined {
|
|
196
|
+
const value = getRetentionSettings()?.startupBudgetMs;
|
|
197
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
198
|
+
return undefined;
|
|
34
199
|
}
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isRewindTurnData(value: unknown): value is RewindTurnData {
|
|
204
|
+
if (!value || typeof value !== "object") return false;
|
|
205
|
+
const data = value as Partial<RewindTurnData>;
|
|
206
|
+
return data.v === 2 && Array.isArray(data.snapshots) && Array.isArray(data.bindings);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isRewindOpData(value: unknown): value is RewindOpData {
|
|
210
|
+
if (!value || typeof value !== "object") return false;
|
|
211
|
+
const data = value as Partial<RewindOpData>;
|
|
212
|
+
return data.v === 2 && Array.isArray(data.snapshots);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function canonicalizePath(value: string): string {
|
|
216
|
+
const resolvedValue = resolve(value);
|
|
35
217
|
try {
|
|
36
|
-
|
|
37
|
-
const settings = JSON.parse(settingsContent);
|
|
38
|
-
cachedSilentCheckpoints = settings.rewind?.silentCheckpoints === true;
|
|
39
|
-
return cachedSilentCheckpoints;
|
|
218
|
+
return realpathSync.native(resolvedValue);
|
|
40
219
|
} catch {
|
|
41
|
-
|
|
42
|
-
return
|
|
220
|
+
// Path may not exist yet; compare with the resolved path directly.
|
|
221
|
+
return resolvedValue;
|
|
43
222
|
}
|
|
44
223
|
}
|
|
45
224
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
225
|
+
function isInsidePath(targetPath: string, parentPath: string): boolean {
|
|
226
|
+
const resolvedTarget = canonicalizePath(targetPath);
|
|
227
|
+
const resolvedParent = canonicalizePath(parentPath);
|
|
228
|
+
const rel = relative(resolvedParent, resolvedTarget);
|
|
229
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function toTimestamp(value: string | undefined): number {
|
|
233
|
+
if (!value) return 0;
|
|
234
|
+
const parsed = Date.parse(value);
|
|
235
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getTextContent(content: unknown): string {
|
|
239
|
+
if (typeof content === "string") return content;
|
|
240
|
+
if (!Array.isArray(content)) return "";
|
|
241
|
+
return content
|
|
242
|
+
.filter((block): block is { type: string; text?: string } => !!block && typeof block === "object")
|
|
243
|
+
.filter((block) => block.type === "text")
|
|
244
|
+
.map((block) => block.text ?? "")
|
|
245
|
+
.join("\n");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function updateLabelSet(labelIds: Set<string>, entry: SessionLikeLabelEntry) {
|
|
249
|
+
if (!entry.targetId) return;
|
|
250
|
+
if (entry.label && entry.label.trim()) {
|
|
251
|
+
labelIds.add(entry.targetId);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
labelIds.delete(entry.targetId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function applyBindings(target: Map<string, string>, snapshots: string[], bindings?: BindingTuple[]) {
|
|
258
|
+
if (!bindings) return;
|
|
259
|
+
for (const [entryId, snapshotIndex] of bindings) {
|
|
260
|
+
const commitSha = snapshots[snapshotIndex];
|
|
261
|
+
if (entryId && commitSha) {
|
|
262
|
+
target.set(entryId, commitSha);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function addReferences(target: ParsedLedgerReference[], snapshots: string[], timestamp: number, data: RewindTurnData | RewindOpData) {
|
|
268
|
+
if ("bindings" in data && data.bindings) {
|
|
269
|
+
for (const [entryId, snapshotIndex] of data.bindings) {
|
|
270
|
+
const commitSha = snapshots[snapshotIndex];
|
|
271
|
+
if (!commitSha) continue;
|
|
272
|
+
target.push({ commitSha, entryId, timestamp, kind: "binding" });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if ("current" in data && typeof data.current === "number") {
|
|
277
|
+
const commitSha = snapshots[data.current];
|
|
278
|
+
if (commitSha) {
|
|
279
|
+
target.push({ commitSha, timestamp, kind: "current" });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if ("undo" in data && typeof data.undo === "number") {
|
|
284
|
+
const commitSha = snapshots[data.undo];
|
|
285
|
+
if (commitSha) {
|
|
286
|
+
target.push({ commitSha, timestamp, kind: "undo" });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function resolveBindingSnapshotIndex(snapshots: string[], commitSha: string): number {
|
|
292
|
+
const existingIndex = snapshots.indexOf(commitSha);
|
|
293
|
+
if (existingIndex >= 0) return existingIndex;
|
|
294
|
+
snapshots.push(commitSha);
|
|
295
|
+
return snapshots.length - 1;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function addBindingToCollector(collector: ActivePromptCollector, entryId: string, commitSha: string) {
|
|
299
|
+
const snapshotIndex = resolveBindingSnapshotIndex(collector.snapshots, commitSha);
|
|
300
|
+
collector.bindings.push([entryId, snapshotIndex]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getCommitFromData(data: RewindOpData, indexKey: "current" | "undo"): string | undefined {
|
|
304
|
+
const snapshotIndex = data[indexKey];
|
|
305
|
+
return typeof snapshotIndex === "number" ? data.snapshots[snapshotIndex] : undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isRestorableTreeEntry(entry: SessionLikeEntry | undefined): boolean {
|
|
309
|
+
if (!entry) return false;
|
|
310
|
+
if (entry.type === "message") {
|
|
311
|
+
return entry.message.role === "user" || entry.message.role === "assistant";
|
|
312
|
+
}
|
|
313
|
+
return entry.type === "branch_summary" || entry.type === "compaction";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isAssistantMessageEntry(entry: SessionLikeEntry): entry is SessionLikeMessageEntry {
|
|
317
|
+
return entry.type === "message" && entry.message.role === "assistant";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function findLatestUserMessageEntry(entries: SessionLikeEntry[]): SessionLikeMessageEntry | null {
|
|
321
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
322
|
+
const entry = entries[index];
|
|
323
|
+
if (entry.type === "message" && entry.message.role === "user") {
|
|
324
|
+
return entry;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function findLatestMatchingUserMessageEntry(
|
|
331
|
+
entries: SessionLikeEntry[],
|
|
332
|
+
promptText: string | null | undefined,
|
|
333
|
+
): SessionLikeMessageEntry | null {
|
|
334
|
+
if (!promptText) return null;
|
|
335
|
+
|
|
336
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
337
|
+
const entry = entries[index];
|
|
338
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
339
|
+
if (getTextContent(entry.message.content) === promptText) {
|
|
340
|
+
return entry;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findAssistantEntryForTurn(entries: SessionLikeEntry[], message: { timestamp?: number; content?: unknown }): SessionLikeMessageEntry | null {
|
|
348
|
+
const targetTimestamp = message.timestamp;
|
|
349
|
+
const targetText = getTextContent(message.content);
|
|
350
|
+
|
|
351
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
352
|
+
const entry = entries[index];
|
|
353
|
+
if (!isAssistantMessageEntry(entry)) continue;
|
|
354
|
+
|
|
355
|
+
if (targetTimestamp !== undefined && entry.message.timestamp === targetTimestamp) {
|
|
356
|
+
return entry;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (targetText && getTextContent(entry.message.content) === targetText) {
|
|
360
|
+
return entry;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return null;
|
|
53
365
|
}
|
|
54
366
|
|
|
55
|
-
export default function (pi: ExtensionAPI) {
|
|
56
|
-
const
|
|
57
|
-
|
|
367
|
+
export default function rewindExtension(pi: ExtensionAPI) {
|
|
368
|
+
const entryToCommit = new Map<string, string>();
|
|
369
|
+
const parsedSessionCache = new Map<string, { mtimeMs: number; ledger: ParsedSessionLedger }>();
|
|
370
|
+
|
|
58
371
|
let repoRoot: string | null = null;
|
|
59
|
-
let isGitRepo = false;
|
|
60
372
|
let sessionId: string | null = null;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
373
|
+
let currentSessionFile: string | undefined;
|
|
374
|
+
let currentParentSession: string | undefined;
|
|
375
|
+
let currentSessionCwd: string | undefined;
|
|
376
|
+
let isGitRepo = false;
|
|
377
|
+
let lastExact: ExactState | null = null;
|
|
378
|
+
let activeBranchState: ActiveBranchState = {};
|
|
379
|
+
let promptCollector: ActivePromptCollector | null = null;
|
|
380
|
+
let pendingForkState: PendingResultingState | null = null;
|
|
381
|
+
let pendingTreeState: PendingResultingState | null = null;
|
|
382
|
+
let activePromptText: string | null = null;
|
|
383
|
+
let newSnapshotsSinceSweep = 0;
|
|
384
|
+
let sweepRunning = false;
|
|
385
|
+
let sweepCompletedThisSession = false;
|
|
386
|
+
let forceConversationOnlyOnNextFork = false;
|
|
387
|
+
let forceConversationOnlySource: string | null = null;
|
|
388
|
+
|
|
389
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info") {
|
|
390
|
+
if (!ctx.hasUI) return;
|
|
391
|
+
if (level === "info" && getSilentCheckpointsSetting()) return;
|
|
392
|
+
ctx.ui.notify(message, level);
|
|
393
|
+
}
|
|
394
|
+
|
|
69
395
|
function updateStatus(ctx: ExtensionContext) {
|
|
70
396
|
if (!ctx.hasUI) return;
|
|
71
|
-
if (getSilentCheckpointsSetting()) {
|
|
397
|
+
if (!isGitRepo || getSilentCheckpointsSetting()) {
|
|
72
398
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
73
399
|
return;
|
|
74
400
|
}
|
|
401
|
+
|
|
402
|
+
const uniqueSnapshots = new Set(entryToCommit.values()).size;
|
|
75
403
|
const theme = ctx.ui.theme;
|
|
76
|
-
|
|
77
|
-
|
|
404
|
+
ctx.ui.setStatus(
|
|
405
|
+
STATUS_KEY,
|
|
406
|
+
theme.fg("dim", "◆ ") + theme.fg("muted", `${entryToCommit.size} points / ${uniqueSnapshots} snapshots`),
|
|
407
|
+
);
|
|
78
408
|
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Reset all state for a fresh session
|
|
82
|
-
*/
|
|
409
|
+
|
|
83
410
|
function resetState() {
|
|
84
|
-
|
|
85
|
-
|
|
411
|
+
entryToCommit.clear();
|
|
412
|
+
parsedSessionCache.clear();
|
|
86
413
|
repoRoot = null;
|
|
87
|
-
isGitRepo = false;
|
|
88
414
|
sessionId = null;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"--format=%(refname)",
|
|
106
|
-
REF_PREFIX,
|
|
107
|
-
]);
|
|
108
|
-
|
|
109
|
-
const refs = result.stdout.trim().split("\n").filter(Boolean);
|
|
110
|
-
|
|
111
|
-
for (const ref of refs) {
|
|
112
|
-
// Get checkpoint ID by removing prefix
|
|
113
|
-
const checkpointId = ref.replace(REF_PREFIX, "");
|
|
114
|
-
|
|
115
|
-
// Skip non-checkpoint refs (before-restore, resume)
|
|
116
|
-
if (!checkpointId.startsWith("checkpoint-")) continue;
|
|
117
|
-
if (checkpointId.startsWith("checkpoint-resume-")) continue;
|
|
118
|
-
|
|
119
|
-
// Try new format first: checkpoint-{sessionId}-{timestamp}-{entryId}
|
|
120
|
-
// Session ID is a UUID (36 chars with hyphens)
|
|
121
|
-
// Timestamp is always numeric (13 digits for ms since epoch)
|
|
122
|
-
// Entry ID comes after the timestamp, may contain hyphens
|
|
123
|
-
const newFormatMatch = checkpointId.match(/^checkpoint-([a-f0-9-]{36})-(\d+)-(.+)$/);
|
|
124
|
-
if (newFormatMatch) {
|
|
125
|
-
const refSessionId = newFormatMatch[1];
|
|
126
|
-
const entryId = newFormatMatch[3];
|
|
127
|
-
// Only load checkpoints from the current session, keep newest (first seen)
|
|
128
|
-
if (refSessionId === currentSessionId && !checkpoints.has(entryId)) {
|
|
129
|
-
checkpoints.set(entryId, checkpointId);
|
|
130
|
-
}
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Try old format: checkpoint-{timestamp}-{entryId} (pre-v1.7.0)
|
|
135
|
-
// Load these for backward compatibility - they belong to whoever resumes the session
|
|
136
|
-
const oldFormatMatch = checkpointId.match(/^checkpoint-(\d+)-(.+)$/);
|
|
137
|
-
if (oldFormatMatch) {
|
|
138
|
-
const entryId = oldFormatMatch[2];
|
|
139
|
-
// Keep newest (first seen), prefer new-format if exists
|
|
140
|
-
if (!checkpoints.has(entryId)) {
|
|
141
|
-
checkpoints.set(entryId, checkpointId);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
415
|
+
currentSessionFile = undefined;
|
|
416
|
+
currentParentSession = undefined;
|
|
417
|
+
currentSessionCwd = undefined;
|
|
418
|
+
isGitRepo = false;
|
|
419
|
+
lastExact = null;
|
|
420
|
+
activeBranchState = {};
|
|
421
|
+
promptCollector = null;
|
|
422
|
+
pendingForkState = null;
|
|
423
|
+
pendingTreeState = null;
|
|
424
|
+
activePromptText = null;
|
|
425
|
+
newSnapshotsSinceSweep = 0;
|
|
426
|
+
sweepCompletedThisSession = false;
|
|
427
|
+
forceConversationOnlyOnNextFork = false;
|
|
428
|
+
forceConversationOnlySource = null;
|
|
429
|
+
cachedSettings = null;
|
|
430
|
+
}
|
|
145
431
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
432
|
+
function syncSessionIdentity(ctx: ExtensionContext) {
|
|
433
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
434
|
+
currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
435
|
+
currentParentSession = ctx.sessionManager.getHeader()?.parentSession;
|
|
436
|
+
currentSessionCwd = ctx.sessionManager.getCwd();
|
|
149
437
|
}
|
|
150
438
|
|
|
151
|
-
async function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
"--sort=-creatordate",
|
|
157
|
-
"--count=1",
|
|
158
|
-
"--format=%(refname) %(objectname)",
|
|
159
|
-
`${REF_PREFIX}${BEFORE_RESTORE_PREFIX}${currentSessionId}-*`,
|
|
160
|
-
]);
|
|
161
|
-
|
|
162
|
-
const line = result.stdout.trim();
|
|
163
|
-
if (!line) return null;
|
|
164
|
-
|
|
165
|
-
const parts = line.split(" ");
|
|
166
|
-
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
|
167
|
-
return { refName: parts[0], commitSha: parts[1] };
|
|
168
|
-
} catch {
|
|
169
|
-
return null;
|
|
439
|
+
async function execGitChecked(args: string[]): Promise<GitExecResult> {
|
|
440
|
+
const result = await pi.exec("git", args);
|
|
441
|
+
if (result.code !== 0) {
|
|
442
|
+
const stderr = result.stderr.trim();
|
|
443
|
+
throw new Error(stderr || `git ${args.join(" ")} failed with code ${result.code}`);
|
|
170
444
|
}
|
|
445
|
+
return result;
|
|
171
446
|
}
|
|
172
447
|
|
|
173
448
|
async function getRepoRoot(exec: ExecFn): Promise<string> {
|
|
174
449
|
if (repoRoot) return repoRoot;
|
|
175
450
|
const result = await exec("git", ["rev-parse", "--show-toplevel"]);
|
|
451
|
+
if (result.code !== 0) {
|
|
452
|
+
const stderr = result.stderr.trim();
|
|
453
|
+
throw new Error(stderr || `git rev-parse --show-toplevel failed with code ${result.code}`);
|
|
454
|
+
}
|
|
176
455
|
repoRoot = result.stdout.trim();
|
|
177
456
|
return repoRoot;
|
|
178
457
|
}
|
|
179
458
|
|
|
180
|
-
|
|
181
|
-
* Capture current worktree state as a git commit (without affecting HEAD).
|
|
182
|
-
* Uses execAsync directly (instead of pi.exec) because we need to set
|
|
183
|
-
* GIT_INDEX_FILE environment variable for an isolated index.
|
|
184
|
-
*/
|
|
185
|
-
async function captureWorktree(): Promise<string> {
|
|
459
|
+
async function captureWorktreeTree(): Promise<{ treeSha: string }> {
|
|
186
460
|
const root = await getRepoRoot(pi.exec);
|
|
187
|
-
const
|
|
188
|
-
const
|
|
461
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-rewind-"));
|
|
462
|
+
const tempIndex = join(tempDir, "index");
|
|
189
463
|
|
|
190
464
|
try {
|
|
191
|
-
const env = { ...process.env, GIT_INDEX_FILE:
|
|
465
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
192
466
|
await execAsync("git add -A", { cwd: root, env });
|
|
193
|
-
const { stdout
|
|
194
|
-
|
|
195
|
-
const result = await pi.exec("git", ["commit-tree", treeSha.trim(), "-m", "rewind backup"]);
|
|
196
|
-
return result.stdout.trim();
|
|
467
|
+
const { stdout } = await execAsync("git write-tree", { cwd: root, env });
|
|
468
|
+
return { treeSha: stdout.trim() };
|
|
197
469
|
} finally {
|
|
198
|
-
await rm(
|
|
470
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
471
|
+
// Best effort cleanup for temporary index directory.
|
|
472
|
+
});
|
|
199
473
|
}
|
|
200
474
|
}
|
|
201
475
|
|
|
202
|
-
async function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
): Promise<boolean> {
|
|
208
|
-
|
|
209
|
-
|
|
476
|
+
async function getCommitTreeSha(commitSha: string): Promise<string> {
|
|
477
|
+
const result = await execGitChecked(["show", "-s", "--format=%T", commitSha]);
|
|
478
|
+
return result.stdout.trim();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function commitExists(commitSha: string): Promise<boolean> {
|
|
482
|
+
const result = await pi.exec("git", ["cat-file", "-e", `${commitSha}^{commit}`]);
|
|
483
|
+
return result.code === 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function getStoreHead(): Promise<string | undefined> {
|
|
487
|
+
const result = await pi.exec("git", ["rev-parse", "--verify", STORE_REF]);
|
|
488
|
+
if (result.code !== 0) {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
const value = result.stdout.trim();
|
|
492
|
+
return value || undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function createStoreKeepaliveCommit(snapshotCommitSha: string, previousStoreHead?: string): Promise<string> {
|
|
496
|
+
const args = ["commit-tree", EMPTY_TREE_SHA];
|
|
497
|
+
|
|
498
|
+
if (previousStoreHead) {
|
|
499
|
+
args.push("-p", previousStoreHead);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
args.push("-p", snapshotCommitSha, "-m", "pi rewind store");
|
|
503
|
+
const result = await execGitChecked(args);
|
|
504
|
+
return result.stdout.trim();
|
|
505
|
+
}
|
|
210
506
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await exec("git", [
|
|
215
|
-
"update-ref",
|
|
216
|
-
`${REF_PREFIX}${newBackupId}`,
|
|
217
|
-
backupCommit,
|
|
218
|
-
]);
|
|
507
|
+
async function appendSnapshotToStore(commitSha: string): Promise<void> {
|
|
508
|
+
let attempts = 0;
|
|
509
|
+
let lastError: unknown;
|
|
219
510
|
|
|
220
|
-
|
|
221
|
-
|
|
511
|
+
while (attempts < 5) {
|
|
512
|
+
attempts += 1;
|
|
513
|
+
const oldHead = await getStoreHead();
|
|
514
|
+
const keepaliveCommit = await createStoreKeepaliveCommit(commitSha, oldHead);
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
if (oldHead) {
|
|
518
|
+
await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, oldHead]);
|
|
519
|
+
} else {
|
|
520
|
+
await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, LEGACY_ZERO_SHA]);
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
} catch (error) {
|
|
524
|
+
// Retry if another process updated the store ref concurrently.
|
|
525
|
+
// Keep the most recent error for actionable failure context.
|
|
526
|
+
lastError = error;
|
|
222
527
|
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
531
|
+
throw new Error(`failed to update rewind store ref: ${detail}`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function rewriteStoreToLiveSet(liveCommitShas: string[]): Promise<"rewritten" | "preserved-empty"> {
|
|
535
|
+
const uniqueLiveCommits = [...new Set(liveCommitShas.filter(Boolean))];
|
|
536
|
+
if (uniqueLiveCommits.length === 0) {
|
|
537
|
+
return "preserved-empty";
|
|
538
|
+
}
|
|
223
539
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
notify(`Failed to restore: ${err}`, "error");
|
|
228
|
-
return false;
|
|
540
|
+
let head: string | undefined;
|
|
541
|
+
for (const commitSha of uniqueLiveCommits) {
|
|
542
|
+
head = await createStoreKeepaliveCommit(commitSha, head);
|
|
229
543
|
}
|
|
544
|
+
|
|
545
|
+
const oldHead = await getStoreHead();
|
|
546
|
+
if (oldHead) {
|
|
547
|
+
await execGitChecked(["update-ref", STORE_REF, head!, oldHead]);
|
|
548
|
+
return "rewritten";
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]);
|
|
552
|
+
return "rewritten";
|
|
230
553
|
}
|
|
231
554
|
|
|
232
|
-
async function
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
await exec("git", [
|
|
236
|
-
"update-ref",
|
|
237
|
-
`${REF_PREFIX}${checkpointId}`,
|
|
238
|
-
commitSha,
|
|
239
|
-
]);
|
|
240
|
-
return true;
|
|
241
|
-
} catch {
|
|
242
|
-
return false;
|
|
555
|
+
async function ensureSnapshotForTree(treeSha: string): Promise<string> {
|
|
556
|
+
if (lastExact && lastExact.treeSha === treeSha) {
|
|
557
|
+
return lastExact.commitSha;
|
|
243
558
|
}
|
|
559
|
+
|
|
560
|
+
const result = await execGitChecked(["commit-tree", treeSha, "-m", "pi rewind snapshot"]);
|
|
561
|
+
const commitSha = result.stdout.trim();
|
|
562
|
+
await appendSnapshotToStore(commitSha);
|
|
563
|
+
lastExact = { commitSha, treeSha };
|
|
564
|
+
newSnapshotsSinceSweep += 1;
|
|
565
|
+
return commitSha;
|
|
244
566
|
}
|
|
245
567
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return entry;
|
|
568
|
+
async function ensureSnapshotForCurrentWorktree(): Promise<string> {
|
|
569
|
+
const { treeSha } = await captureWorktreeTree();
|
|
570
|
+
return ensureSnapshotForTree(treeSha);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function deletePathsFromWorkingTree(paths: string[]) {
|
|
574
|
+
if (paths.length === 0) return;
|
|
575
|
+
const root = await getRepoRoot(pi.exec);
|
|
576
|
+
|
|
577
|
+
for (const repoRelativePath of paths) {
|
|
578
|
+
const absolutePath = resolve(root, repoRelativePath);
|
|
579
|
+
if (!isInsidePath(absolutePath, root)) {
|
|
580
|
+
throw new Error(`refusing to delete path outside repo root: ${repoRelativePath}`);
|
|
260
581
|
}
|
|
582
|
+
await rm(absolutePath, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function getDeletedPaths(currentTreeSha: string, targetTreeSha: string): Promise<string[]> {
|
|
587
|
+
const result = await execGitChecked([
|
|
588
|
+
"diff",
|
|
589
|
+
"--name-only",
|
|
590
|
+
"--diff-filter=D",
|
|
591
|
+
"-z",
|
|
592
|
+
currentTreeSha,
|
|
593
|
+
targetTreeSha,
|
|
594
|
+
"--",
|
|
595
|
+
]);
|
|
596
|
+
|
|
597
|
+
return result.stdout.split("\0").filter(Boolean);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function restoreCommitExactly(targetCommitSha: string): Promise<{ changed: boolean; undoCommitSha?: string; targetTreeSha: string }> {
|
|
601
|
+
const { treeSha: currentTreeSha } = await captureWorktreeTree();
|
|
602
|
+
const targetTreeSha = await getCommitTreeSha(targetCommitSha);
|
|
603
|
+
|
|
604
|
+
if (currentTreeSha === targetTreeSha) {
|
|
605
|
+
lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
|
|
606
|
+
return { changed: false, targetTreeSha };
|
|
261
607
|
}
|
|
262
|
-
|
|
608
|
+
|
|
609
|
+
const undoCommitSha = await ensureSnapshotForTree(currentTreeSha);
|
|
610
|
+
const pathsToDelete = await getDeletedPaths(currentTreeSha, targetTreeSha);
|
|
611
|
+
await deletePathsFromWorkingTree(pathsToDelete);
|
|
612
|
+
await execGitChecked(["restore", `--source=${targetCommitSha}`, "--worktree", "--", "."]);
|
|
613
|
+
lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
|
|
614
|
+
return { changed: true, undoCommitSha, targetTreeSha };
|
|
263
615
|
}
|
|
264
616
|
|
|
265
|
-
|
|
617
|
+
function bindPendingPromptUser(entries: SessionLikeEntry[], collector: ActivePromptCollector) {
|
|
618
|
+
if (!collector.pendingUserCommitSha) return;
|
|
619
|
+
|
|
620
|
+
const userEntry = findLatestMatchingUserMessageEntry(entries, collector.promptText) ?? findLatestUserMessageEntry(entries);
|
|
621
|
+
if (!userEntry) return;
|
|
622
|
+
if (collector.bindings.some(([entryId]) => entryId === userEntry.id)) {
|
|
623
|
+
collector.pendingUserCommitSha = undefined;
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
addBindingToCollector(collector, userEntry.id, collector.pendingUserCommitSha);
|
|
628
|
+
collector.pendingUserCommitSha = undefined;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function appendRewindTurn(ctx: ExtensionContext, collector: ActivePromptCollector) {
|
|
632
|
+
if (collector.bindings.length === 0) return;
|
|
633
|
+
|
|
634
|
+
const data: RewindTurnData = {
|
|
635
|
+
v: RETENTION_VERSION,
|
|
636
|
+
snapshots: collector.snapshots,
|
|
637
|
+
bindings: collector.bindings,
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
pi.appendEntry("rewind-turn", data);
|
|
641
|
+
applyBindings(entryToCommit, data.snapshots, data.bindings);
|
|
642
|
+
|
|
643
|
+
const latestBinding = data.bindings[data.bindings.length - 1];
|
|
644
|
+
if (latestBinding) {
|
|
645
|
+
activeBranchState.currentCommitSha = data.snapshots[latestBinding[1]];
|
|
646
|
+
activeBranchState.currentTreeSha = lastExact?.commitSha === activeBranchState.currentCommitSha ? lastExact.treeSha : undefined;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
updateStatus(ctx);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function appendRewindOp(ctx: ExtensionContext, data: RewindOpData) {
|
|
653
|
+
const hasBindings = Boolean(data.bindings?.length);
|
|
654
|
+
const hasCurrent = typeof data.current === "number";
|
|
655
|
+
const hasUndo = typeof data.undo === "number";
|
|
656
|
+
if (!hasBindings && !hasCurrent && !hasUndo) return;
|
|
657
|
+
|
|
658
|
+
pi.appendEntry("rewind-op", data);
|
|
659
|
+
applyBindings(entryToCommit, data.snapshots, data.bindings);
|
|
660
|
+
|
|
661
|
+
const currentCommitSha = getCommitFromData(data, "current");
|
|
662
|
+
if (currentCommitSha) {
|
|
663
|
+
activeBranchState.currentCommitSha = currentCommitSha;
|
|
664
|
+
activeBranchState.currentTreeSha = lastExact?.commitSha === currentCommitSha ? lastExact.treeSha : undefined;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const undoCommitSha = getCommitFromData(data, "undo");
|
|
668
|
+
if (undoCommitSha) {
|
|
669
|
+
activeBranchState.undoCommitSha = undoCommitSha;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
updateStatus(ctx);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function buildCurrentSessionLedger(ctx: ExtensionContext): ParsedSessionLedger {
|
|
676
|
+
const ledger: ParsedSessionLedger = {
|
|
677
|
+
sessionFile: currentSessionFile ?? "",
|
|
678
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
679
|
+
cwd: ctx.sessionManager.getCwd(),
|
|
680
|
+
parentSession: ctx.sessionManager.getHeader()?.parentSession,
|
|
681
|
+
entryToCommit: new Map<string, string>(),
|
|
682
|
+
labeledEntryIds: new Set<string>(),
|
|
683
|
+
references: [],
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
for (const rawEntry of ctx.sessionManager.getEntries() as SessionLikeEntry[]) {
|
|
687
|
+
if (rawEntry.type === "custom" && rawEntry.customType === "rewind-turn" && isRewindTurnData(rawEntry.data)) {
|
|
688
|
+
applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings);
|
|
689
|
+
addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (rawEntry.type === "custom" && rawEntry.customType === "rewind-op" && isRewindOpData(rawEntry.data)) {
|
|
694
|
+
applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings);
|
|
695
|
+
addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data);
|
|
696
|
+
const currentCommitSha = getCommitFromData(rawEntry.data, "current");
|
|
697
|
+
if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha;
|
|
698
|
+
const undoCommitSha = getCommitFromData(rawEntry.data, "undo");
|
|
699
|
+
if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (rawEntry.type === "label") {
|
|
704
|
+
updateLabelSet(ledger.labeledEntryIds, rawEntry);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return ledger;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function parseSessionLedgerFile(sessionFile: string): Promise<ParsedSessionLedger | null> {
|
|
266
712
|
try {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
]);
|
|
273
|
-
|
|
274
|
-
const refs = result.stdout.trim().split("\n").filter(Boolean);
|
|
275
|
-
// Filter to only regular checkpoints from THIS session (not backups, resume, or other sessions)
|
|
276
|
-
const checkpointRefs = refs.filter(r => {
|
|
277
|
-
if (r.includes(BEFORE_RESTORE_PREFIX)) return false;
|
|
278
|
-
if (r.includes("checkpoint-resume-")) return false;
|
|
279
|
-
// Only include refs from current session
|
|
280
|
-
const checkpointId = r.replace(REF_PREFIX, "");
|
|
281
|
-
return checkpointId.startsWith(`checkpoint-${currentSessionId}-`);
|
|
282
|
-
});
|
|
713
|
+
const fileStat = await stat(sessionFile);
|
|
714
|
+
const cached = parsedSessionCache.get(sessionFile);
|
|
715
|
+
if (cached && cached.mtimeMs === fileStat.mtimeMs) {
|
|
716
|
+
return cached.ledger;
|
|
717
|
+
}
|
|
283
718
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
719
|
+
const content = await readFile(sessionFile, "utf-8");
|
|
720
|
+
const ledger: ParsedSessionLedger = {
|
|
721
|
+
sessionFile,
|
|
722
|
+
entryToCommit: new Map<string, string>(),
|
|
723
|
+
labeledEntryIds: new Set<string>(),
|
|
724
|
+
references: [],
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const hasRewindEntries = content.includes('"rewind-');
|
|
728
|
+
|
|
729
|
+
if (!hasRewindEntries) {
|
|
730
|
+
// Fast path: extract session header only, skip line-by-line JSON parsing
|
|
731
|
+
let pos = 0;
|
|
732
|
+
for (let i = 0; i < 5 && pos < content.length; i++) {
|
|
733
|
+
const nextNewline = content.indexOf("\n", pos);
|
|
734
|
+
const line = nextNewline >= 0 ? content.substring(pos, nextNewline) : content.substring(pos);
|
|
735
|
+
pos = nextNewline >= 0 ? nextNewline + 1 : content.length;
|
|
736
|
+
if (!line) continue;
|
|
737
|
+
try {
|
|
738
|
+
const entry = JSON.parse(line);
|
|
739
|
+
if (entry?.type === "session") {
|
|
740
|
+
ledger.sessionId = entry.id;
|
|
741
|
+
ledger.cwd = entry.cwd;
|
|
742
|
+
ledger.parentSession = entry.parentSession;
|
|
743
|
+
break;
|
|
297
744
|
}
|
|
745
|
+
} catch {
|
|
746
|
+
// Ignore malformed header lines from partial/corrupt session files.
|
|
747
|
+
continue;
|
|
298
748
|
}
|
|
299
749
|
}
|
|
750
|
+
parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger });
|
|
751
|
+
return ledger;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const lines = content.split("\n").filter(Boolean);
|
|
755
|
+
|
|
756
|
+
for (const line of lines) {
|
|
757
|
+
let entry: any;
|
|
758
|
+
try {
|
|
759
|
+
entry = JSON.parse(line);
|
|
760
|
+
} catch {
|
|
761
|
+
// Ignore malformed JSONL lines; retention discovery is best-effort.
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (entry?.type === "session") {
|
|
766
|
+
ledger.sessionId = entry.id;
|
|
767
|
+
ledger.cwd = entry.cwd;
|
|
768
|
+
ledger.parentSession = entry.parentSession;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (entry?.type === "custom" && entry?.customType === "rewind-turn" && isRewindTurnData(entry.data)) {
|
|
773
|
+
applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings);
|
|
774
|
+
addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (entry?.type === "custom" && entry?.customType === "rewind-op" && isRewindOpData(entry.data)) {
|
|
779
|
+
applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings);
|
|
780
|
+
addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data);
|
|
781
|
+
const currentCommitSha = getCommitFromData(entry.data, "current");
|
|
782
|
+
if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha;
|
|
783
|
+
const undoCommitSha = getCommitFromData(entry.data, "undo");
|
|
784
|
+
if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (entry?.type === "label") {
|
|
789
|
+
updateLabelSet(ledger.labeledEntryIds, entry);
|
|
790
|
+
}
|
|
300
791
|
}
|
|
792
|
+
|
|
793
|
+
parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger });
|
|
794
|
+
return ledger;
|
|
301
795
|
} catch {
|
|
302
|
-
//
|
|
796
|
+
// Unreadable/missing session file: skip it and continue lineage/discovery.
|
|
797
|
+
return null;
|
|
303
798
|
}
|
|
304
799
|
}
|
|
305
800
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
*/
|
|
309
|
-
async function initializeForSession(ctx: ExtensionContext) {
|
|
310
|
-
if (!ctx.hasUI) return;
|
|
801
|
+
async function resolveEntrySnapshotWithLineage(entryId: string, sessionFile = currentSessionFile): Promise<string | undefined> {
|
|
802
|
+
let cursor = sessionFile;
|
|
311
803
|
|
|
312
|
-
|
|
313
|
-
|
|
804
|
+
while (cursor) {
|
|
805
|
+
const ledger = cursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(cursor);
|
|
806
|
+
if (!ledger) break;
|
|
314
807
|
|
|
315
|
-
|
|
316
|
-
|
|
808
|
+
const commitSha = ledger.entryToCommit.get(entryId);
|
|
809
|
+
if (commitSha && (await commitExists(commitSha))) {
|
|
810
|
+
return commitSha;
|
|
811
|
+
}
|
|
317
812
|
|
|
318
|
-
|
|
319
|
-
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
320
|
-
isGitRepo = result.stdout.trim() === "true";
|
|
321
|
-
} catch {
|
|
322
|
-
isGitRepo = false;
|
|
813
|
+
cursor = ledger.parentSession;
|
|
323
814
|
}
|
|
324
815
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
816
|
+
return undefined;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function buildCurrentSessionLedgerFromMemory(): ParsedSessionLedger {
|
|
820
|
+
return {
|
|
821
|
+
sessionFile: currentSessionFile ?? "",
|
|
822
|
+
sessionId: sessionId ?? undefined,
|
|
823
|
+
cwd: currentSessionCwd,
|
|
824
|
+
parentSession: currentParentSession,
|
|
825
|
+
entryToCommit: new Map(entryToCommit),
|
|
826
|
+
labeledEntryIds: new Set(),
|
|
827
|
+
references: [],
|
|
828
|
+
latestCurrentCommitSha: activeBranchState.currentCommitSha,
|
|
829
|
+
latestUndoCommitSha: activeBranchState.undoCommitSha,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function reconstructState(ctx: ExtensionContext) {
|
|
834
|
+
entryToCommit.clear();
|
|
835
|
+
activeBranchState = {};
|
|
836
|
+
lastExact = null;
|
|
837
|
+
|
|
838
|
+
const currentLedger = buildCurrentSessionLedger(ctx);
|
|
839
|
+
for (const [entryId, commitSha] of currentLedger.entryToCommit.entries()) {
|
|
840
|
+
entryToCommit.set(entryId, commitSha);
|
|
328
841
|
}
|
|
329
842
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
843
|
+
let latestVisibleBindingCommitSha: string | undefined;
|
|
844
|
+
for (const entry of ctx.sessionManager.getBranch() as SessionLikeEntry[]) {
|
|
845
|
+
const boundCommitSha = entry.id ? entryToCommit.get(entry.id) : undefined;
|
|
846
|
+
if (boundCommitSha && isRestorableTreeEntry(entry)) {
|
|
847
|
+
latestVisibleBindingCommitSha = boundCommitSha;
|
|
848
|
+
}
|
|
333
849
|
|
|
334
|
-
|
|
335
|
-
|
|
850
|
+
if (entry.type === "custom" && entry.customType === "rewind-op" && isRewindOpData(entry.data)) {
|
|
851
|
+
const currentCommitSha = getCommitFromData(entry.data, "current");
|
|
852
|
+
if (currentCommitSha) {
|
|
853
|
+
activeBranchState.currentCommitSha = currentCommitSha;
|
|
854
|
+
}
|
|
855
|
+
const undoCommitSha = getCommitFromData(entry.data, "undo");
|
|
856
|
+
if (undoCommitSha) {
|
|
857
|
+
activeBranchState.undoCommitSha = undoCommitSha;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
336
861
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
862
|
+
if (!activeBranchState.currentCommitSha) {
|
|
863
|
+
activeBranchState.currentCommitSha = latestVisibleBindingCommitSha;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (activeBranchState.currentCommitSha && (await commitExists(activeBranchState.currentCommitSha))) {
|
|
867
|
+
activeBranchState.currentTreeSha = await getCommitTreeSha(activeBranchState.currentCommitSha);
|
|
868
|
+
const { treeSha: worktreeTreeSha } = await captureWorktreeTree();
|
|
869
|
+
|
|
870
|
+
if (activeBranchState.currentTreeSha === worktreeTreeSha) {
|
|
871
|
+
lastExact = {
|
|
872
|
+
commitSha: activeBranchState.currentCommitSha,
|
|
873
|
+
treeSha: activeBranchState.currentTreeSha,
|
|
874
|
+
};
|
|
341
875
|
}
|
|
342
|
-
} catch {
|
|
343
|
-
// Silent failure - resume checkpoint is optional
|
|
344
876
|
}
|
|
345
|
-
|
|
346
|
-
updateStatus(ctx);
|
|
347
877
|
}
|
|
348
878
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
pi.on("session_switch", async (_event, ctx) => {
|
|
354
|
-
await initializeForSession(ctx);
|
|
355
|
-
});
|
|
879
|
+
async function discoverSessionFiles(scanMode: "ancestor-only" | "repo-sessions"): Promise<string[]> {
|
|
880
|
+
const discovered = new Set<string>();
|
|
356
881
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
882
|
+
if (scanMode === "repo-sessions") {
|
|
883
|
+
const roots = new Set<string>();
|
|
884
|
+
const defaultSessionsDir = getDefaultSessionsDir();
|
|
885
|
+
if (existsSync(defaultSessionsDir)) {
|
|
886
|
+
roots.add(defaultSessionsDir);
|
|
887
|
+
}
|
|
888
|
+
if (currentSessionFile) {
|
|
889
|
+
roots.add(dirname(currentSessionFile));
|
|
890
|
+
}
|
|
365
891
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
892
|
+
const stack = [...roots];
|
|
893
|
+
while (stack.length > 0) {
|
|
894
|
+
const dir = stack.pop();
|
|
895
|
+
if (!dir) continue;
|
|
896
|
+
|
|
897
|
+
let entries: Awaited<ReturnType<typeof readdir>>;
|
|
898
|
+
try {
|
|
899
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
900
|
+
} catch {
|
|
901
|
+
// Skip unreadable directories during best-effort discovery.
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
for (const entry of entries) {
|
|
906
|
+
const fullPath = join(dir, entry.name);
|
|
907
|
+
if (entry.isDirectory()) {
|
|
908
|
+
stack.push(fullPath);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
912
|
+
discovered.add(fullPath);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
374
916
|
}
|
|
375
|
-
});
|
|
376
917
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
918
|
+
let ancestorCursor = currentSessionFile;
|
|
919
|
+
while (ancestorCursor) {
|
|
920
|
+
discovered.add(ancestorCursor);
|
|
921
|
+
const ledger = ancestorCursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(ancestorCursor);
|
|
922
|
+
ancestorCursor = ledger?.parentSession;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return [...discovered];
|
|
926
|
+
}
|
|
386
927
|
|
|
928
|
+
async function maybeSweepRetention(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
|
|
929
|
+
const retention = getRetentionSettings();
|
|
930
|
+
if (!retention) return;
|
|
931
|
+
if (reason === "new-snapshots" && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
|
|
932
|
+
if (reason === "shutdown" && sweepCompletedThisSession && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
|
|
933
|
+
if (!repoRoot) return;
|
|
934
|
+
if (sweepRunning) return;
|
|
935
|
+
sweepRunning = true;
|
|
387
936
|
try {
|
|
388
|
-
|
|
389
|
-
if (!userEntry) return;
|
|
390
|
-
|
|
391
|
-
const entryId = userEntry.id;
|
|
392
|
-
const sanitizedEntryId = sanitizeForRef(entryId);
|
|
393
|
-
// Include session ID in checkpoint name to scope it per-session
|
|
394
|
-
const checkpointId = `checkpoint-${sessionId}-${pendingCheckpoint.timestamp}-${sanitizedEntryId}`;
|
|
395
|
-
|
|
396
|
-
// Create the git ref for this checkpoint
|
|
397
|
-
await pi.exec("git", [
|
|
398
|
-
"update-ref",
|
|
399
|
-
`${REF_PREFIX}${checkpointId}`,
|
|
400
|
-
pendingCheckpoint.commitSha,
|
|
401
|
-
]);
|
|
402
|
-
|
|
403
|
-
checkpoints.set(sanitizedEntryId, checkpointId);
|
|
404
|
-
await pruneCheckpoints(pi.exec, sessionId);
|
|
405
|
-
updateStatus(ctx);
|
|
406
|
-
if (!getSilentCheckpointsSetting()) {
|
|
407
|
-
ctx.ui.notify(`Checkpoint ${checkpoints.size} saved`, "info");
|
|
408
|
-
}
|
|
409
|
-
} catch {
|
|
410
|
-
// Silent failure - checkpoint creation is not critical
|
|
937
|
+
await runRetentionSweep(ctx, reason);
|
|
411
938
|
} finally {
|
|
412
|
-
|
|
939
|
+
sweepRunning = false;
|
|
413
940
|
}
|
|
414
|
-
}
|
|
941
|
+
}
|
|
415
942
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if (!
|
|
943
|
+
async function runRetentionSweep(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
|
|
944
|
+
const retention = getRetentionSettings();
|
|
945
|
+
if (!retention) return;
|
|
419
946
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
947
|
+
const scanMode = getRetentionScanMode();
|
|
948
|
+
const startupBudgetMs = reason === "startup" ? getStartupSweepBudgetMs() : undefined;
|
|
949
|
+
const startedAt = Date.now();
|
|
950
|
+
|
|
951
|
+
const budgetExceeded = () => typeof startupBudgetMs === "number" && Date.now() - startedAt > startupBudgetMs;
|
|
952
|
+
|
|
953
|
+
const sessionFiles = await discoverSessionFiles(scanMode);
|
|
954
|
+
if (budgetExceeded()) {
|
|
955
|
+
notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
|
|
424
956
|
return;
|
|
425
957
|
}
|
|
426
958
|
|
|
427
|
-
const
|
|
428
|
-
let checkpointId = checkpoints.get(sanitizedEntryId);
|
|
429
|
-
let usingResumeCheckpoint = false;
|
|
959
|
+
const ledgers: ParsedSessionLedger[] = [];
|
|
430
960
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
961
|
+
for (const sessionFile of sessionFiles) {
|
|
962
|
+
if (budgetExceeded()) {
|
|
963
|
+
notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const ledger = sessionFile === currentSessionFile ? buildCurrentSessionLedger(ctx) : await parseSessionLedgerFile(sessionFile);
|
|
968
|
+
if (!ledger?.cwd) continue;
|
|
969
|
+
if (!isInsidePath(ledger.cwd, repoRoot)) continue;
|
|
970
|
+
ledgers.push(ledger);
|
|
434
971
|
}
|
|
435
972
|
|
|
436
|
-
const
|
|
437
|
-
const
|
|
973
|
+
const latestReferenceByCommit = new Map<string, number>();
|
|
974
|
+
const pinnedCommits = new Set<string>();
|
|
975
|
+
const currentCommits = new Set<string>();
|
|
976
|
+
const undoCommits = new Set<string>();
|
|
438
977
|
|
|
439
|
-
const
|
|
978
|
+
for (const ledger of ledgers) {
|
|
979
|
+
if (budgetExceeded()) {
|
|
980
|
+
notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
440
983
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
984
|
+
for (const reference of ledger.references) {
|
|
985
|
+
const prev = latestReferenceByCommit.get(reference.commitSha) ?? 0;
|
|
986
|
+
if (reference.timestamp > prev) {
|
|
987
|
+
latestReferenceByCommit.set(reference.commitSha, reference.timestamp);
|
|
988
|
+
}
|
|
989
|
+
if (reference.kind === "binding" && retention.pinLabeledEntries && reference.entryId && ledger.labeledEntryIds.has(reference.entryId)) {
|
|
990
|
+
pinnedCommits.add(reference.commitSha);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (ledger.latestCurrentCommitSha) {
|
|
995
|
+
currentCommits.add(ledger.latestCurrentCommitSha);
|
|
996
|
+
}
|
|
997
|
+
if (ledger.latestUndoCommitSha) {
|
|
998
|
+
undoCommits.add(ledger.latestUndoCommitSha);
|
|
451
999
|
}
|
|
452
1000
|
}
|
|
453
1001
|
|
|
454
|
-
|
|
455
|
-
|
|
1002
|
+
for (const commitSha of [...currentCommits, ...undoCommits]) {
|
|
1003
|
+
if (budgetExceeded()) {
|
|
1004
|
+
notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (await commitExists(commitSha)) {
|
|
1008
|
+
pinnedCommits.add(commitSha);
|
|
1009
|
+
}
|
|
456
1010
|
}
|
|
457
1011
|
|
|
458
|
-
|
|
1012
|
+
let candidates = [...latestReferenceByCommit.entries()]
|
|
1013
|
+
.filter(([commitSha]) => !pinnedCommits.has(commitSha))
|
|
1014
|
+
.sort((left, right) => right[1] - left[1]);
|
|
459
1015
|
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
1016
|
+
if (typeof retention.maxAgeDays === "number" && retention.maxAgeDays >= 0) {
|
|
1017
|
+
const cutoff = Date.now() - retention.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
1018
|
+
candidates = candidates.filter(([, timestamp]) => timestamp >= cutoff);
|
|
463
1019
|
}
|
|
464
1020
|
|
|
465
|
-
if (
|
|
466
|
-
|
|
1021
|
+
if (typeof retention.maxSnapshots === "number" && retention.maxSnapshots >= 0 && candidates.length > retention.maxSnapshots) {
|
|
1022
|
+
candidates = candidates.slice(0, retention.maxSnapshots);
|
|
467
1023
|
}
|
|
468
1024
|
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
);
|
|
479
|
-
if (success) {
|
|
480
|
-
ctx.ui.notify("Files restored to before last rewind", "info");
|
|
1025
|
+
const liveSet = [...new Set([...pinnedCommits, ...candidates.map(([commitSha]) => commitSha)])];
|
|
1026
|
+
const existingLiveSet: string[] = [];
|
|
1027
|
+
for (const commitSha of liveSet) {
|
|
1028
|
+
if (budgetExceeded()) {
|
|
1029
|
+
notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (await commitExists(commitSha)) {
|
|
1033
|
+
existingLiveSet.push(commitSha);
|
|
481
1034
|
}
|
|
482
|
-
return { cancel: true };
|
|
483
1035
|
}
|
|
484
1036
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
return
|
|
1037
|
+
const rewriteResult = await rewriteStoreToLiveSet(existingLiveSet);
|
|
1038
|
+
if (rewriteResult === "preserved-empty") {
|
|
1039
|
+
return;
|
|
488
1040
|
}
|
|
489
1041
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// (restoreWithBackup already notified the user of the error)
|
|
501
|
-
return { cancel: true };
|
|
1042
|
+
// Skip gc on background startup sweeps to avoid racing with concurrent snapshot creation
|
|
1043
|
+
if (reason !== "startup") {
|
|
1044
|
+
try {
|
|
1045
|
+
const result = await pi.exec("git", ["gc", "--auto"]);
|
|
1046
|
+
if (result.code !== 0) {
|
|
1047
|
+
throw new Error(result.stderr.trim() || `git gc --auto failed with code ${result.code}`);
|
|
1048
|
+
}
|
|
1049
|
+
} catch {
|
|
1050
|
+
// Best effort only; retention reachability rewrite already completed.
|
|
1051
|
+
}
|
|
502
1052
|
}
|
|
503
|
-
|
|
504
|
-
ctx.ui.notify(
|
|
505
|
-
usingResumeCheckpoint
|
|
506
|
-
? "Files restored to session start"
|
|
507
|
-
: "Files restored from checkpoint",
|
|
508
|
-
"info"
|
|
509
|
-
);
|
|
510
1053
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
1054
|
+
newSnapshotsSinceSweep = 0;
|
|
1055
|
+
sweepCompletedThisSession = true;
|
|
1056
|
+
updateStatus(ctx);
|
|
1057
|
+
}
|
|
515
1058
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
1059
|
+
async function initializeForSession(ctx: ExtensionContext) {
|
|
1060
|
+
resetState();
|
|
1061
|
+
syncSessionIdentity(ctx);
|
|
519
1062
|
|
|
520
1063
|
try {
|
|
521
1064
|
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
|
|
522
|
-
|
|
1065
|
+
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
|
|
523
1066
|
} catch {
|
|
1067
|
+
// Treat git probing failures as non-git context for this session.
|
|
1068
|
+
isGitRepo = false;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (!isGitRepo) {
|
|
1072
|
+
updateStatus(ctx);
|
|
524
1073
|
return;
|
|
525
1074
|
}
|
|
526
1075
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
1076
|
+
await getRepoRoot(pi.exec);
|
|
1077
|
+
await reconstructState(ctx);
|
|
1078
|
+
updateStatus(ctx);
|
|
1079
|
+
maybeSweepRetention(ctx, "startup").catch((error) => {
|
|
1080
|
+
notify(ctx, `Rewind retention startup sweep failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
pi.events.on("rewind:fork-preference", (data: any) => {
|
|
1085
|
+
if (data?.mode !== "conversation-only") return;
|
|
1086
|
+
if (typeof data?.source !== "string") return;
|
|
1087
|
+
if (!FORK_PREFERENCE_SOURCE_ALLOWLIST.has(data.source)) return;
|
|
1088
|
+
forceConversationOnlyOnNextFork = true;
|
|
1089
|
+
forceConversationOnlySource = data.source;
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
pi.on("before_agent_start", async (event) => {
|
|
1093
|
+
activePromptText = event.prompt;
|
|
1094
|
+
});
|
|
531
1095
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1096
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1097
|
+
await initializeForSession(ctx);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
1101
|
+
await initializeForSession(ctx);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
pi.on("session_fork", async (_event, ctx) => {
|
|
1105
|
+
syncSessionIdentity(ctx);
|
|
1106
|
+
if (!isGitRepo || !pendingForkState) {
|
|
1107
|
+
await reconstructState(ctx);
|
|
1108
|
+
updateStatus(ctx);
|
|
1109
|
+
return;
|
|
535
1110
|
}
|
|
536
1111
|
|
|
537
|
-
const
|
|
538
|
-
const
|
|
1112
|
+
const snapshots = [pendingForkState.currentCommitSha];
|
|
1113
|
+
const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
|
|
1114
|
+
if (pendingForkState.undoCommitSha) {
|
|
1115
|
+
data.snapshots.push(pendingForkState.undoCommitSha);
|
|
1116
|
+
data.undo = 1;
|
|
1117
|
+
}
|
|
539
1118
|
|
|
540
|
-
|
|
1119
|
+
appendRewindOp(ctx, data);
|
|
1120
|
+
pendingForkState = null;
|
|
1121
|
+
await reconstructState(ctx);
|
|
1122
|
+
updateStatus(ctx);
|
|
1123
|
+
});
|
|
541
1124
|
|
|
542
|
-
|
|
543
|
-
|
|
1125
|
+
pi.on("session_tree", async (event, ctx) => {
|
|
1126
|
+
syncSessionIdentity(ctx);
|
|
1127
|
+
if (!isGitRepo || !pendingTreeState) {
|
|
1128
|
+
await reconstructState(ctx);
|
|
1129
|
+
updateStatus(ctx);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
544
1132
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1133
|
+
const snapshots = [pendingTreeState.currentCommitSha];
|
|
1134
|
+
const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
|
|
1135
|
+
if (pendingTreeState.undoCommitSha) {
|
|
1136
|
+
data.snapshots.push(pendingTreeState.undoCommitSha);
|
|
1137
|
+
data.undo = 1;
|
|
1138
|
+
}
|
|
1139
|
+
if (event.summaryEntry?.id) {
|
|
1140
|
+
data.bindings = [[event.summaryEntry.id, 0]];
|
|
551
1141
|
}
|
|
552
1142
|
|
|
553
|
-
|
|
554
|
-
|
|
1143
|
+
appendRewindOp(ctx, data);
|
|
1144
|
+
pendingTreeState = null;
|
|
1145
|
+
await reconstructState(ctx);
|
|
1146
|
+
updateStatus(ctx);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
1150
|
+
syncSessionIdentity(ctx);
|
|
1151
|
+
if (!isGitRepo) return;
|
|
1152
|
+
|
|
1153
|
+
let currentCommitSha = activeBranchState.currentCommitSha;
|
|
1154
|
+
if (!currentCommitSha) {
|
|
1155
|
+
currentCommitSha = await ensureSnapshotForCurrentWorktree();
|
|
555
1156
|
}
|
|
556
1157
|
|
|
557
|
-
|
|
1158
|
+
appendRewindOp(ctx, {
|
|
1159
|
+
v: RETENTION_VERSION,
|
|
1160
|
+
snapshots: [currentCommitSha],
|
|
1161
|
+
bindings: [[event.compactionEntry.id, 0]],
|
|
1162
|
+
});
|
|
1163
|
+
await reconstructState(ctx);
|
|
1164
|
+
updateStatus(ctx);
|
|
1165
|
+
});
|
|
558
1166
|
|
|
559
|
-
|
|
1167
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1168
|
+
syncSessionIdentity(ctx);
|
|
1169
|
+
if (!isGitRepo) return;
|
|
1170
|
+
await maybeSweepRetention(ctx, "shutdown");
|
|
1171
|
+
});
|
|
560
1172
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1173
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
1174
|
+
if (!isGitRepo) return;
|
|
1175
|
+
if (event.turnIndex !== 0) return;
|
|
1176
|
+
|
|
1177
|
+
try {
|
|
1178
|
+
const { treeSha } = await captureWorktreeTree();
|
|
1179
|
+
const commitSha = await ensureSnapshotForTree(treeSha);
|
|
1180
|
+
promptCollector = {
|
|
1181
|
+
snapshots: [],
|
|
1182
|
+
bindings: [],
|
|
1183
|
+
promptText: activePromptText ?? undefined,
|
|
1184
|
+
pendingUserCommitSha: commitSha,
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector);
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
promptCollector = null;
|
|
1190
|
+
notify(ctx, `Rewind: failed to capture start snapshot (${error instanceof Error ? error.message : String(error)})`, "warning");
|
|
564
1191
|
}
|
|
1192
|
+
});
|
|
565
1193
|
|
|
566
|
-
|
|
567
|
-
|
|
1194
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
1195
|
+
if (!isGitRepo || !promptCollector) return;
|
|
1196
|
+
|
|
1197
|
+
try {
|
|
1198
|
+
const branchEntries = ctx.sessionManager.getBranch() as SessionLikeEntry[];
|
|
1199
|
+
bindPendingPromptUser(branchEntries, promptCollector);
|
|
1200
|
+
|
|
1201
|
+
if (event.message.role !== "assistant") return;
|
|
1202
|
+
|
|
1203
|
+
const assistantEntry = findAssistantEntryForTurn(branchEntries, event.message);
|
|
1204
|
+
if (!assistantEntry) return;
|
|
1205
|
+
|
|
1206
|
+
const { treeSha } = await captureWorktreeTree();
|
|
1207
|
+
const commitSha = await ensureSnapshotForTree(treeSha);
|
|
1208
|
+
addBindingToCollector(promptCollector, assistantEntry.id, commitSha);
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
notify(ctx, `Rewind: failed to capture assistant snapshot (${error instanceof Error ? error.message : String(error)})`, "warning");
|
|
568
1211
|
}
|
|
1212
|
+
});
|
|
569
1213
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
);
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1214
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
1215
|
+
if (!isGitRepo || !promptCollector) return;
|
|
1216
|
+
|
|
1217
|
+
try {
|
|
1218
|
+
bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector);
|
|
1219
|
+
appendRewindTurn(ctx, promptCollector);
|
|
1220
|
+
await reconstructState(ctx);
|
|
1221
|
+
updateStatus(ctx);
|
|
1222
|
+
await maybeSweepRetention(ctx, "new-snapshots");
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
notify(ctx, `Rewind: failed to finalize rewind turn (${error instanceof Error ? error.message : String(error)})`, "warning");
|
|
1225
|
+
} finally {
|
|
1226
|
+
promptCollector = null;
|
|
1227
|
+
activePromptText = null;
|
|
581
1228
|
}
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
pi.on("session_before_fork", async (event, ctx) => {
|
|
1232
|
+
const shouldForceConversationOnly = forceConversationOnlyOnNextFork;
|
|
1233
|
+
const forcedBySource = forceConversationOnlySource;
|
|
1234
|
+
forceConversationOnlyOnNextFork = false;
|
|
1235
|
+
forceConversationOnlySource = null;
|
|
1236
|
+
|
|
1237
|
+
if (!isGitRepo) return;
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
if (!ctx.hasUI) {
|
|
1241
|
+
pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const targetCommitSha = await resolveEntrySnapshotWithLineage(event.entryId);
|
|
1246
|
+
const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha)));
|
|
1247
|
+
|
|
1248
|
+
if (shouldForceConversationOnly) {
|
|
1249
|
+
pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
|
|
1250
|
+
notify(ctx, `Rewind: using conversation-only fork (keep current files)${forcedBySource ? ` (${forcedBySource})` : ""}`);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const options = ["Conversation only (keep current files)"];
|
|
1255
|
+
if (targetCommitSha) {
|
|
1256
|
+
options.push("Restore all (files + conversation)", "Code only (restore files, keep conversation)");
|
|
1257
|
+
}
|
|
1258
|
+
if (hasUndo) {
|
|
1259
|
+
options.push("Undo last file rewind");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const choice = await ctx.ui.select("Restore Options", options);
|
|
1263
|
+
if (!choice) {
|
|
1264
|
+
notify(ctx, "Rewind cancelled");
|
|
1265
|
+
return { cancel: true };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (choice === "Undo last file rewind") {
|
|
1269
|
+
const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!);
|
|
1270
|
+
pendingForkState = {
|
|
1271
|
+
currentCommitSha: activeBranchState.undoCommitSha!,
|
|
1272
|
+
undoCommitSha: restore.undoCommitSha,
|
|
1273
|
+
};
|
|
1274
|
+
notify(ctx, "Files restored to before last rewind");
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
582
1277
|
|
|
583
|
-
|
|
584
|
-
|
|
1278
|
+
if (choice === "Conversation only (keep current files)") {
|
|
1279
|
+
pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (!targetCommitSha) {
|
|
1284
|
+
notify(ctx, "No exact rewind point available for that entry", "error");
|
|
1285
|
+
return { cancel: true };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const restore = await restoreCommitExactly(targetCommitSha);
|
|
1289
|
+
pendingForkState = {
|
|
1290
|
+
currentCommitSha: targetCommitSha,
|
|
1291
|
+
undoCommitSha: restore.undoCommitSha,
|
|
1292
|
+
};
|
|
1293
|
+
notify(ctx, "Files restored from rewind point");
|
|
1294
|
+
|
|
1295
|
+
if (choice === "Code only (restore files, keep conversation)") {
|
|
1296
|
+
return { skipConversationRestore: true };
|
|
1297
|
+
}
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
pendingForkState = null;
|
|
1300
|
+
notify(ctx, `Rewind failed before fork: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
585
1301
|
return { cancel: true };
|
|
586
1302
|
}
|
|
1303
|
+
});
|
|
587
1304
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1305
|
+
pi.on("session_before_tree", async (event, ctx) => {
|
|
1306
|
+
if (!isGitRepo || !ctx.hasUI) return;
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
const targetEntry = ctx.sessionManager.getEntry(event.preparation.targetId) as SessionLikeEntry | undefined;
|
|
1310
|
+
const targetCommitSha = isRestorableTreeEntry(targetEntry)
|
|
1311
|
+
? await resolveEntrySnapshotWithLineage(event.preparation.targetId, currentSessionFile)
|
|
1312
|
+
: undefined;
|
|
1313
|
+
const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha)));
|
|
1314
|
+
|
|
1315
|
+
const options = ["Keep current files"];
|
|
1316
|
+
if (targetCommitSha) {
|
|
1317
|
+
options.push("Restore files to that point");
|
|
1318
|
+
}
|
|
1319
|
+
if (hasUndo) {
|
|
1320
|
+
options.push("Undo last file rewind");
|
|
1321
|
+
}
|
|
1322
|
+
options.push("Cancel navigation");
|
|
1323
|
+
|
|
1324
|
+
const choice = await ctx.ui.select("Restore Options", options);
|
|
1325
|
+
if (!choice || choice === "Cancel navigation") {
|
|
1326
|
+
notify(ctx, "Navigation cancelled");
|
|
1327
|
+
return { cancel: true };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (choice === "Undo last file rewind") {
|
|
1331
|
+
const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!);
|
|
1332
|
+
const snapshots = [activeBranchState.undoCommitSha!];
|
|
1333
|
+
const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
|
|
1334
|
+
if (restore.undoCommitSha) {
|
|
1335
|
+
data.snapshots.push(restore.undoCommitSha);
|
|
1336
|
+
data.undo = 1;
|
|
1337
|
+
}
|
|
1338
|
+
appendRewindOp(ctx, data);
|
|
1339
|
+
notify(ctx, "Files restored to before last rewind");
|
|
1340
|
+
await reconstructState(ctx);
|
|
1341
|
+
return { cancel: true };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (choice === "Keep current files") {
|
|
1345
|
+
pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (!targetCommitSha) {
|
|
1350
|
+
notify(ctx, "Exact file rewind is only available for user, assistant, compaction, and summary nodes", "error");
|
|
1351
|
+
return { cancel: true };
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const restore = await restoreCommitExactly(targetCommitSha);
|
|
1355
|
+
pendingTreeState = {
|
|
1356
|
+
currentCommitSha: targetCommitSha,
|
|
1357
|
+
undoCommitSha: restore.undoCommitSha,
|
|
1358
|
+
};
|
|
1359
|
+
notify(ctx, "Files restored to rewind point");
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
pendingTreeState = null;
|
|
1362
|
+
notify(ctx, `Rewind failed before tree navigation: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
599
1363
|
return { cancel: true };
|
|
600
1364
|
}
|
|
601
|
-
|
|
602
|
-
ctx.ui.notify(
|
|
603
|
-
usingResumeCheckpoint
|
|
604
|
-
? "Files restored to session start"
|
|
605
|
-
: "Files restored to checkpoint",
|
|
606
|
-
"info"
|
|
607
|
-
);
|
|
608
1365
|
});
|
|
609
|
-
|
|
610
1366
|
}
|