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/index.ts CHANGED
@@ -1,610 +1,1366 @@
1
1
  /**
2
- * Rewind Extension - Git-based file restoration for pi branching
2
+ * Rewind Extension - session-ledger based exact file restoration for pi branching
3
3
  *
4
- * Creates worktree snapshots at the start of each agent loop (when user sends a message)
5
- * so /branch and tree navigation can restore code state.
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 { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
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 { homedir, tmpdir } from "os";
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 REF_PREFIX = "refs/pi-checkpoints/";
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 SETTINGS_FILE = join(homedir(), ".pi", "agent", "settings.json");
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
- let cachedSilentCheckpoints: boolean | null = null;
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
- if (cachedSilentCheckpoints !== null) {
33
- return cachedSilentCheckpoints;
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
- const settingsContent = readFileSync(SETTINGS_FILE, "utf-8");
37
- const settings = JSON.parse(settingsContent);
38
- cachedSilentCheckpoints = settings.rewind?.silentCheckpoints === true;
39
- return cachedSilentCheckpoints;
218
+ return realpathSync.native(resolvedValue);
40
219
  } catch {
41
- cachedSilentCheckpoints = false;
42
- return false;
220
+ // Path may not exist yet; compare with the resolved path directly.
221
+ return resolvedValue;
43
222
  }
44
223
  }
45
224
 
46
- /**
47
- * Sanitize entry ID for use in git ref names.
48
- * Git refs can't contain: space, ~, ^, :, ?, *, [, \, or control chars.
49
- * Entry IDs are typically alphanumeric but we sanitize just in case.
50
- */
51
- function sanitizeForRef(id: string): string {
52
- return id.replace(/[^a-zA-Z0-9-]/g, "_");
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 checkpoints = new Map<string, string>();
57
- let resumeCheckpoint: string | null = null;
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
- // Pending checkpoint: worktree state captured at turn_start, waiting for turn_end
63
- // to associate with the correct user message entry ID
64
- let pendingCheckpoint: { commitSha: string; timestamp: number } | null = null;
65
-
66
- /**
67
- * Update the footer status with checkpoint count
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
- const count = checkpoints.size;
77
- ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", "◆ ") + theme.fg("muted", `${count} checkpoint${count === 1 ? "" : "s"}`));
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
- checkpoints.clear();
85
- resumeCheckpoint = null;
411
+ entryToCommit.clear();
412
+ parsedSessionCache.clear();
86
413
  repoRoot = null;
87
- isGitRepo = false;
88
414
  sessionId = null;
89
- pendingCheckpoint = null;
90
- cachedSilentCheckpoints = null;
91
- }
92
-
93
- /**
94
- * Rebuild the checkpoints map from existing git refs.
95
- * Supports two formats for backward compatibility:
96
- * - New format: `checkpoint-{sessionId}-{timestamp}-{entryId}` (session-scoped)
97
- * - Old format: `checkpoint-{timestamp}-{entryId}` (pre-v1.7.0, loaded for current session)
98
- * This allows checkpoint restoration to work across session resumes.
99
- */
100
- async function rebuildCheckpointsMap(exec: ExecFn, currentSessionId: string): Promise<void> {
101
- try {
102
- const result = await exec("git", [
103
- "for-each-ref",
104
- "--sort=-creatordate", // Newest first - we keep first match per entry
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
- } catch {
147
- // Silent failure - checkpoints will be recreated as needed
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 findBeforeRestoreRef(exec: ExecFn, currentSessionId: string): Promise<{ refName: string; commitSha: string } | null> {
152
- try {
153
- // Look for before-restore refs scoped to this session
154
- const result = await exec("git", [
155
- "for-each-ref",
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 tmpDir = await mkdtemp(join(tmpdir(), "pi-rewind-"));
188
- const tmpIndex = join(tmpDir, "index");
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: tmpIndex };
465
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
192
466
  await execAsync("git add -A", { cwd: root, env });
193
- const { stdout: treeSha } = await execAsync("git write-tree", { cwd: root, env });
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(tmpDir, { recursive: true, force: true }).catch(() => {});
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 restoreWithBackup(
203
- exec: ExecFn,
204
- targetRef: string,
205
- currentSessionId: string,
206
- notify: (msg: string, level: "info" | "warning" | "error") => void
207
- ): Promise<boolean> {
208
- try {
209
- const existingBackup = await findBeforeRestoreRef(exec, currentSessionId);
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
- const backupCommit = await captureWorktree();
212
- // Include session ID in before-restore ref to scope it per-session
213
- const newBackupId = `${BEFORE_RESTORE_PREFIX}${currentSessionId}-${Date.now()}`;
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
- if (existingBackup) {
221
- await exec("git", ["update-ref", "-d", existingBackup.refName]);
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
- await exec("git", ["checkout", targetRef, "--", "."]);
225
- return true;
226
- } catch (err) {
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 createCheckpointFromWorktree(exec: ExecFn, checkpointId: string): Promise<boolean> {
233
- try {
234
- const commitSha = await captureWorktree();
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
- * Find the most recent user message in the current branch.
248
- * Used at turn_end to find the user message that triggered the agent loop.
249
- */
250
- function findUserMessageEntry(sessionManager: { getLeafId(): string | null; getBranch(id?: string): any[] }): { id: string } | null {
251
- const leafId = sessionManager.getLeafId();
252
- if (!leafId) return null;
253
-
254
- const branch = sessionManager.getBranch(leafId);
255
- // Walk backwards to find the most recent user message
256
- for (let i = branch.length - 1; i >= 0; i--) {
257
- const entry = branch[i];
258
- if (entry.type === "message" && entry.message?.role === "user") {
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
- return null;
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
- async function pruneCheckpoints(exec: ExecFn, currentSessionId: string) {
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 result = await exec("git", [
268
- "for-each-ref",
269
- "--sort=creatordate",
270
- "--format=%(refname)",
271
- REF_PREFIX,
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
- if (checkpointRefs.length > MAX_CHECKPOINTS) {
285
- const toDelete = checkpointRefs.slice(0, checkpointRefs.length - MAX_CHECKPOINTS);
286
- for (const ref of toDelete) {
287
- await exec("git", ["update-ref", "-d", ref]);
288
-
289
- // Remove from in-memory map ONLY if this is the currently mapped checkpoint.
290
- // There might be a newer checkpoint for the same entry that we're keeping.
291
- const checkpointId = ref.replace(REF_PREFIX, "");
292
- const match = checkpointId.match(/^checkpoint-([a-f0-9-]{36})-(\d+)-(.+)$/);
293
- if (match) {
294
- const entryId = match[3];
295
- if (checkpoints.get(entryId) === checkpointId) {
296
- checkpoints.delete(entryId);
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
- // Silent failure - pruning is not critical
796
+ // Unreadable/missing session file: skip it and continue lineage/discovery.
797
+ return null;
303
798
  }
304
799
  }
305
800
 
306
- /**
307
- * Initialize the extension for the current session/repo
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
- // Reset all state for fresh initialization
313
- resetState();
804
+ while (cursor) {
805
+ const ledger = cursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(cursor);
806
+ if (!ledger) break;
314
807
 
315
- // Capture session ID for scoping checkpoints
316
- sessionId = ctx.sessionManager.getSessionId();
808
+ const commitSha = ledger.entryToCommit.get(entryId);
809
+ if (commitSha && (await commitExists(commitSha))) {
810
+ return commitSha;
811
+ }
317
812
 
318
- try {
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
- if (!isGitRepo) {
326
- ctx.ui.setStatus(STATUS_KEY, undefined);
327
- return;
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
- // Rebuild checkpoints map from existing git refs (for resumed sessions)
331
- // Only loads checkpoints belonging to this session
332
- await rebuildCheckpointsMap(pi.exec, sessionId);
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
- // Create a resume checkpoint for the current state (session-scoped like other checkpoints)
335
- const checkpointId = `checkpoint-resume-${sessionId}-${Date.now()}`;
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
- try {
338
- const success = await createCheckpointFromWorktree(pi.exec, checkpointId);
339
- if (success) {
340
- resumeCheckpoint = checkpointId;
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
- pi.on("session_start", async (_event, ctx) => {
350
- await initializeForSession(ctx);
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
- pi.on("turn_start", async (event, ctx) => {
358
- if (!ctx.hasUI) return;
359
- if (!isGitRepo) return;
360
-
361
- // Only capture at the start of a new agent loop (first turn).
362
- // This is when a user message triggers the agent - we want to snapshot
363
- // the file state BEFORE any tools execute.
364
- if (event.turnIndex !== 0) return;
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
- try {
367
- // Capture worktree state now, but don't create the ref yet.
368
- // At this point, the user message hasn't been appended to the session,
369
- // so we don't know its entry ID. We'll create the ref at turn_end.
370
- const commitSha = await captureWorktree();
371
- pendingCheckpoint = { commitSha, timestamp: event.timestamp };
372
- } catch {
373
- pendingCheckpoint = null;
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
- pi.on("turn_end", async (event, ctx) => {
378
- if (!ctx.hasUI) return;
379
- if (!isGitRepo) return;
380
- if (!pendingCheckpoint) return;
381
- if (!sessionId) return;
382
-
383
- // Only process at end of first turn - by now the user message has been
384
- // appended to the session and we can find its entry ID.
385
- if (event.turnIndex !== 0) return;
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
- const userEntry = findUserMessageEntry(ctx.sessionManager);
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
- pendingCheckpoint = null;
939
+ sweepRunning = false;
413
940
  }
414
- });
941
+ }
415
942
 
416
- pi.on("session_before_fork", async (event, ctx) => {
417
- if (!ctx.hasUI) return;
418
- if (!sessionId) return;
943
+ async function runRetentionSweep(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
944
+ const retention = getRetentionSettings();
945
+ if (!retention) return;
419
946
 
420
- try {
421
- const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
422
- if (result.stdout.trim() !== "true") return;
423
- } catch {
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 sanitizedEntryId = sanitizeForRef(event.entryId);
428
- let checkpointId = checkpoints.get(sanitizedEntryId);
429
- let usingResumeCheckpoint = false;
959
+ const ledgers: ParsedSessionLedger[] = [];
430
960
 
431
- if (!checkpointId && resumeCheckpoint) {
432
- checkpointId = resumeCheckpoint;
433
- usingResumeCheckpoint = true;
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 beforeRestoreRef = await findBeforeRestoreRef(pi.exec, sessionId);
437
- const hasUndo = !!beforeRestoreRef;
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 options: string[] = [];
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
- // Conversation-only (non-file-restorative) first as most common action
442
- options.push("Conversation only (keep current files)");
443
-
444
- if (checkpointId) {
445
- if (usingResumeCheckpoint) {
446
- options.push("Restore to session start (files + conversation)");
447
- options.push("Restore to session start (files only, keep conversation)");
448
- } else {
449
- options.push("Restore all (files + conversation)");
450
- options.push("Code only (restore files, keep conversation)");
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
- if (hasUndo) {
455
- options.push("Undo last file rewind");
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
- const choice = await ctx.ui.select("Restore Options", options);
1012
+ let candidates = [...latestReferenceByCommit.entries()]
1013
+ .filter(([commitSha]) => !pinnedCommits.has(commitSha))
1014
+ .sort((left, right) => right[1] - left[1]);
459
1015
 
460
- if (!choice) {
461
- ctx.ui.notify("Rewind cancelled", "info");
462
- return { cancel: true };
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 (choice.startsWith("Conversation only")) {
466
- return;
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 isCodeOnly = choice === "Code only (restore files, keep conversation)" ||
470
- choice === "Restore to session start (files only, keep conversation)";
471
-
472
- if (choice === "Undo last file rewind") {
473
- const success = await restoreWithBackup(
474
- pi.exec,
475
- beforeRestoreRef!.commitSha,
476
- sessionId,
477
- ctx.ui.notify.bind(ctx.ui)
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
- if (!checkpointId) {
486
- ctx.ui.notify("No checkpoint available", "error");
487
- return { cancel: true };
1037
+ const rewriteResult = await rewriteStoreToLiveSet(existingLiveSet);
1038
+ if (rewriteResult === "preserved-empty") {
1039
+ return;
488
1040
  }
489
1041
 
490
- const ref = `${REF_PREFIX}${checkpointId}`;
491
- const success = await restoreWithBackup(
492
- pi.exec,
493
- ref,
494
- sessionId,
495
- ctx.ui.notify.bind(ctx.ui)
496
- );
497
-
498
- if (!success) {
499
- // File restore failed - cancel the branch operation entirely
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
- if (isCodeOnly) {
512
- return { skipConversationRestore: true };
513
- }
514
- });
1054
+ newSnapshotsSinceSweep = 0;
1055
+ sweepCompletedThisSession = true;
1056
+ updateStatus(ctx);
1057
+ }
515
1058
 
516
- pi.on("session_before_tree", async (event, ctx) => {
517
- if (!ctx.hasUI) return;
518
- if (!sessionId) return;
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
- if (result.stdout.trim() !== "true") return;
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
- const targetId = event.preparation.targetId;
528
- const sanitizedTargetId = sanitizeForRef(targetId);
529
- let checkpointId = checkpoints.get(sanitizedTargetId);
530
- let usingResumeCheckpoint = false;
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
- if (!checkpointId && resumeCheckpoint) {
533
- checkpointId = resumeCheckpoint;
534
- usingResumeCheckpoint = true;
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 beforeRestoreRef = await findBeforeRestoreRef(pi.exec, sessionId);
538
- const hasUndo = !!beforeRestoreRef;
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
- const options: string[] = [];
1119
+ appendRewindOp(ctx, data);
1120
+ pendingForkState = null;
1121
+ await reconstructState(ctx);
1122
+ updateStatus(ctx);
1123
+ });
541
1124
 
542
- // Keep current files first as most common action (non-file-restorative navigation)
543
- options.push("Keep current files");
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
- if (checkpointId) {
546
- if (usingResumeCheckpoint) {
547
- options.push("Restore files to session start");
548
- } else {
549
- options.push("Restore files to that point");
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
- if (hasUndo) {
554
- options.push("Undo last file rewind");
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
- options.push("Cancel navigation");
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
- const choice = await ctx.ui.select("Restore Options", options);
1167
+ pi.on("session_shutdown", async (_event, ctx) => {
1168
+ syncSessionIdentity(ctx);
1169
+ if (!isGitRepo) return;
1170
+ await maybeSweepRetention(ctx, "shutdown");
1171
+ });
560
1172
 
561
- if (!choice || choice === "Cancel navigation") {
562
- ctx.ui.notify("Navigation cancelled", "info");
563
- return { cancel: true };
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
- if (choice === "Keep current files") {
567
- return;
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
- if (choice === "Undo last file rewind") {
571
- const success = await restoreWithBackup(
572
- pi.exec,
573
- beforeRestoreRef!.commitSha,
574
- sessionId,
575
- ctx.ui.notify.bind(ctx.ui)
576
- );
577
- if (success) {
578
- ctx.ui.notify("Files restored to before last rewind", "info");
579
- }
580
- return { cancel: true };
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
- if (!checkpointId) {
584
- ctx.ui.notify("No checkpoint available", "error");
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
- const ref = `${REF_PREFIX}${checkpointId}`;
589
- const success = await restoreWithBackup(
590
- pi.exec,
591
- ref,
592
- sessionId,
593
- ctx.ui.notify.bind(ctx.ui)
594
- );
595
-
596
- if (!success) {
597
- // File restore failed - cancel navigation
598
- // (restoreWithBackup already notified the user of the error)
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
  }