@bachi/pi-coder 1.0.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/config/AGENTS.md +100 -0
  5. package/config/pi-statusline.json +140 -0
  6. package/config/settings.json +38 -0
  7. package/config/web-search.json +5 -0
  8. package/docs/README.md +14 -0
  9. package/docs/configuration.md +123 -0
  10. package/docs/development.md +177 -0
  11. package/docs/extensions.md +292 -0
  12. package/docs/handbook.zh.md +432 -0
  13. package/docs/installation.md +124 -0
  14. package/docs/themes.md +107 -0
  15. package/extensions/ask-user-question/answers.test.ts +104 -0
  16. package/extensions/ask-user-question/answers.ts +72 -0
  17. package/extensions/ask-user-question/dialog.test.ts +180 -0
  18. package/extensions/ask-user-question/dialog.ts +102 -0
  19. package/extensions/ask-user-question/index.ts +253 -0
  20. package/extensions/ask-user-question/model.test.ts +275 -0
  21. package/extensions/ask-user-question/model.ts +259 -0
  22. package/extensions/ask-user-question/schema.ts +49 -0
  23. package/extensions/ask-user-question/types.ts +86 -0
  24. package/extensions/ask-user-question/validate.test.ts +183 -0
  25. package/extensions/ask-user-question/validate.ts +110 -0
  26. package/extensions/ask-user-question/view.ts +262 -0
  27. package/extensions/auto-default-model/default-model.test.ts +268 -0
  28. package/extensions/auto-default-model/index.ts +87 -0
  29. package/extensions/bash-command-collapse.ts +1476 -0
  30. package/extensions/below-editor-after-statusline.ts +118 -0
  31. package/extensions/clear-command.ts +29 -0
  32. package/extensions/cwd-statusline.ts +39 -0
  33. package/extensions/exit-command.ts +59 -0
  34. package/extensions/fenceless-code-block/index.test.ts +208 -0
  35. package/extensions/fenceless-code-block/index.ts +28 -0
  36. package/extensions/fenceless-code-block/render.test.ts +177 -0
  37. package/extensions/fenceless-code-block/render.ts +142 -0
  38. package/extensions/folder-history.ts +197 -0
  39. package/extensions/init-command.ts +163 -0
  40. package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
  41. package/extensions/prompt-editor/bash-prompt.ts +59 -0
  42. package/extensions/prompt-editor/render.test.ts +283 -0
  43. package/extensions/prompt-editor.ts +212 -0
  44. package/extensions/read-path-collapse.ts +474 -0
  45. package/extensions/recap/index.test.ts +348 -0
  46. package/extensions/recap/index.ts +462 -0
  47. package/extensions/recap/subagents.test.ts +144 -0
  48. package/extensions/recap/subagents.ts +128 -0
  49. package/extensions/rewind/README.md +229 -0
  50. package/extensions/rewind/checkpoints.test.ts +560 -0
  51. package/extensions/rewind/checkpoints.ts +820 -0
  52. package/extensions/rewind/flow.test.ts +756 -0
  53. package/extensions/rewind/flow.ts +362 -0
  54. package/extensions/rewind/index.ts +400 -0
  55. package/extensions/rewind/picker.ts +135 -0
  56. package/extensions/rewind/viewport.test.ts +76 -0
  57. package/extensions/rewind/viewport.ts +48 -0
  58. package/extensions/simple-task/gap.test.ts +147 -0
  59. package/extensions/simple-task/gap.ts +122 -0
  60. package/extensions/simple-task/index.ts +439 -0
  61. package/extensions/simple-task/types.ts +53 -0
  62. package/extensions/simple-task/widget.ts +86 -0
  63. package/extensions/startup-logo/header-guard.test.ts +274 -0
  64. package/extensions/startup-logo/header-guard.ts +166 -0
  65. package/extensions/startup-logo/index.test.ts +305 -0
  66. package/extensions/startup-logo/index.ts +194 -0
  67. package/extensions/startup-logo/loaded-sections.test.ts +257 -0
  68. package/extensions/startup-logo/loaded-sections.ts +267 -0
  69. package/extensions/startup-logo/logo.test.ts +124 -0
  70. package/extensions/startup-logo/logo.ts +124 -0
  71. package/extensions/statusline/footer-guard.test.ts +273 -0
  72. package/extensions/statusline/footer-guard.ts +171 -0
  73. package/extensions/statusline/git.test.ts +174 -0
  74. package/extensions/statusline/git.ts +142 -0
  75. package/extensions/statusline/index.ts +294 -0
  76. package/extensions/statusline/line.test.ts +316 -0
  77. package/extensions/statusline/line.ts +201 -0
  78. package/extensions/subagent-log-guard/filter.test.ts +85 -0
  79. package/extensions/subagent-log-guard/filter.ts +32 -0
  80. package/extensions/subagent-log-guard/index.ts +112 -0
  81. package/extensions/theme-command.ts +263 -0
  82. package/extensions/thinking-collapse/window.test.ts +321 -0
  83. package/extensions/thinking-collapse/window.ts +354 -0
  84. package/extensions/thinking-collapse.ts +60 -0
  85. package/extensions/tool-diff/title-row.test.ts +254 -0
  86. package/extensions/tool-diff/title-row.ts +191 -0
  87. package/extensions/tool-diff.ts +1276 -0
  88. package/extensions/working-indicator/bash-spinner.test.ts +135 -0
  89. package/extensions/working-indicator/bash-spinner.ts +114 -0
  90. package/extensions/working-indicator/index.test.ts +579 -0
  91. package/extensions/working-indicator/index.ts +940 -0
  92. package/extensions/working-indicator/spinner-frames.test.ts +219 -0
  93. package/extensions/working-indicator/spinner-frames.ts +156 -0
  94. package/extensions/working-indicator/summary-request.test.ts +195 -0
  95. package/extensions/working-indicator/summary-request.ts +207 -0
  96. package/extensions/working-indicator/working-summary.test.ts +499 -0
  97. package/extensions/working-indicator/working-summary.ts +375 -0
  98. package/package.json +71 -0
  99. package/themes/ayu.json +97 -0
  100. package/themes/catppuccin.json +103 -0
  101. package/themes/summer-night.json +87 -0
@@ -0,0 +1,820 @@
1
+ /**
2
+ * pi-rewind core — checkpoint storage on a shadow git repository.
3
+ *
4
+ * Zero pi imports: pure git + fs operations so the whole module is testable
5
+ * with `node --test`.
6
+ *
7
+ * Why a shadow repo instead of refs in the user's repository (the approach
8
+ * pi-rewind/checkpoint-pi use):
9
+ * - the user's repo keeps zero extra refs, objects and history entries;
10
+ * - the user's real index/HEAD are never touched, so a restore cannot move
11
+ * HEAD or discard staged work (Claude Code behaves the same way: its
12
+ * checkpoints "live beside your git history, not inside it");
13
+ * - checkpointing also works when the working directory is not a git repo.
14
+ *
15
+ * A checkpoint is a worktree tree object plus a commit that keeps it reachable
16
+ * through refs/pi-rewind/<id>, so `git gc --auto` can never prune a live
17
+ * checkpoint. Snapshotting respects .gitignore (plus the real repo's
18
+ * info/exclude, copied into the shadow repo once), so node_modules and friends
19
+ * are never copied into snapshots.
20
+ */
21
+
22
+ import { spawn, spawnSync } from "node:child_process";
23
+ import { createHash } from "node:crypto";
24
+ import {
25
+ copyFileSync,
26
+ existsSync,
27
+ mkdirSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ rmSync,
31
+ statSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
35
+
36
+ /** Ref namespace that keeps checkpoint objects reachable inside the shadow repo. */
37
+ export const REF_PREFIX = "refs/pi-rewind";
38
+
39
+ /** Claude Code keeps file snapshots for the 100 most recent checkpoints per session. */
40
+ export const MAX_CHECKPOINTS_PER_SESSION = 100;
41
+
42
+ /** Claude Code's retention sweep deletes snapshots ~30 days after the session last saved one. */
43
+ export const RETENTION_DAYS = 30;
44
+
45
+ /** Pre-images larger than this are skipped: they would bloat the shadow store. */
46
+ export const MAX_EXTERNAL_FILE_BYTES = 8 * 1024 * 1024;
47
+
48
+ export type CheckpointKind = "session-start" | "prompt" | "safety";
49
+
50
+ /**
51
+ * Pre-image of a file the worktree snapshot cannot see — one outside the
52
+ * project root, or one inside it that `.gitignore` hides from `git add -A`.
53
+ *
54
+ * `blob` names a content-addressed copy under <projectDir>/files, or is null
55
+ * when the file did not exist at capture time (so a restore deletes it).
56
+ */
57
+ export interface ExternalFile {
58
+ /** Absolute path of the file on disk. */
59
+ path: string;
60
+ /** Content-addressed blob name, or null for "did not exist". */
61
+ blob: string | null;
62
+ }
63
+
64
+ export interface Checkpoint {
65
+ /** Unique id; also the shadow-repo ref name suffix. */
66
+ id: string;
67
+ /** Session this checkpoint belongs to. */
68
+ sessionId: string;
69
+ /** "session-start" = snapshot taken when the session opened,
70
+ * "prompt" = snapshot taken before a user prompt,
71
+ * "safety" = snapshot taken right before a code restore (undo net). */
72
+ kind: CheckpointKind;
73
+ /** User prompt text ("" for session-start / safety checkpoints). */
74
+ prompt: string;
75
+ /** Session entry id of the user message, once resolved. Conversation
76
+ * restore navigates the session tree to this entry. */
77
+ entryId?: string;
78
+ /** Worktree tree sha in the shadow repo. */
79
+ tree: string;
80
+ /** Pre-images of files this checkpoint's turn edited outside the worktree
81
+ * snapshot (recorded lazily, first touch wins). */
82
+ files?: ExternalFile[];
83
+ /** Commit sha holding the tree (reachable via refs/pi-rewind/<id>). */
84
+ commit: string;
85
+ /** Epoch ms. */
86
+ timestamp: number;
87
+ }
88
+
89
+ export interface RewindStore {
90
+ /** Absolute path of the snapshot working tree (the project directory). */
91
+ root: string;
92
+ /** Shadow GIT_DIR — outside the project, never the project's own .git. */
93
+ gitDir: string;
94
+ /** Metadata file holding every session's checkpoint list. */
95
+ metaFile: string;
96
+ /** Directory holding the pre-image blobs referenced by `files`. */
97
+ filesDir: string;
98
+ /** Stable per-project key (hash of the root path). */
99
+ projectKey: string;
100
+ }
101
+
102
+ export interface DiffEntry {
103
+ /** git name-status letter: A / M / D / R… / T */
104
+ status: string;
105
+ path: string;
106
+ }
107
+
108
+ export interface CheckpointDiff {
109
+ entries: DiffEntry[];
110
+ /** True when the two trees are identical (nothing to restore). */
111
+ empty: boolean;
112
+ }
113
+
114
+ export interface RestoreResult {
115
+ /** Untracked files that were deleted because the checkpoint did not have them. */
116
+ removed: string[];
117
+ }
118
+
119
+ interface StoreMeta {
120
+ version: 1;
121
+ root: string;
122
+ updatedAt: number;
123
+ sessions: Record<string, Checkpoint[]>;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // git plumbing
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export interface GitRunOptions {
131
+ input?: string;
132
+ /** Resolve with stderr instead of rejecting on a non-zero exit code. */
133
+ allowFailure?: boolean;
134
+ }
135
+
136
+ /** Run git against the shadow repo. Args are passed as an array (no shell). */
137
+ export function git(
138
+ store: RewindStore,
139
+ args: string[],
140
+ options: GitRunOptions = {},
141
+ ): Promise<string> {
142
+ return new Promise((resolve, reject) => {
143
+ const child = spawn("git", args, {
144
+ cwd: store.root,
145
+ env: shadowEnv(store),
146
+ stdio: ["pipe", "pipe", "pipe"],
147
+ });
148
+
149
+ let stdout = "";
150
+ let stderr = "";
151
+ child.stdout.on("data", (chunk) => (stdout += chunk));
152
+ child.stderr.on("data", (chunk) => (stderr += chunk));
153
+ child.on("error", reject);
154
+ child.on("close", (code) => {
155
+ if (code === 0) resolve(stdout.trim());
156
+ else if (options.allowFailure) resolve(stderr.trim());
157
+ else reject(new Error(stderr.trim() || `git ${args[0]} failed (code ${code})`));
158
+ });
159
+
160
+ if (child.stdin) {
161
+ if (options.input) child.stdin.write(options.input);
162
+ child.stdin.end();
163
+ }
164
+ });
165
+ }
166
+
167
+ /** git env for the shadow repo: strips inherited git plumbing variables so a
168
+ * stray GIT_DIR/GIT_INDEX_FILE in pi's environment can never redirect us. */
169
+ function shadowEnv(store: RewindStore): NodeJS.ProcessEnv {
170
+ const env: NodeJS.ProcessEnv = { ...process.env };
171
+ for (const key of [
172
+ "GIT_DIR",
173
+ "GIT_WORK_TREE",
174
+ "GIT_INDEX_FILE",
175
+ "GIT_OBJECT_DIRECTORY",
176
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
177
+ "GIT_NAMESPACE",
178
+ "GIT_COMMON_DIR",
179
+ ]) {
180
+ delete env[key];
181
+ }
182
+ env.GIT_DIR = store.gitDir;
183
+ env.GIT_WORK_TREE = store.root;
184
+ // commit-tree needs an identity; checkpoint commits are never authored by the user.
185
+ env.GIT_AUTHOR_NAME = "pi-rewind";
186
+ env.GIT_AUTHOR_EMAIL = "pi-rewind@pi";
187
+ env.GIT_COMMITTER_NAME = "pi-rewind";
188
+ env.GIT_COMMITTER_EMAIL = "pi-rewind@pi";
189
+ return env;
190
+ }
191
+
192
+ /** Absolute path of the directory that holds every project's shadow repo. */
193
+ export function rewindBaseDir(agentDir: string): string {
194
+ return join(agentDir, "rewind");
195
+ }
196
+
197
+ /** Stable per-project key so two checkouts of the same repo do not collide. */
198
+ export function projectKeyFor(root: string): string {
199
+ return createHash("sha1").update(root).digest("hex").slice(0, 16);
200
+ }
201
+
202
+ /**
203
+ * Open (and on first use create) the shadow repo for `root`.
204
+ * Idempotent; safe to call on every session start.
205
+ */
206
+ export function openStore(root: string, agentDir: string): RewindStore {
207
+ const absoluteRoot = resolveRoot(root);
208
+ const projectKey = projectKeyFor(absoluteRoot);
209
+ const projectDir = join(rewindBaseDir(agentDir), projectKey);
210
+ const gitDir = join(projectDir, "git");
211
+ const metaFile = join(projectDir, "checkpoints.json");
212
+ const filesDir = join(projectDir, "files");
213
+
214
+ mkdirSync(projectDir, { recursive: true });
215
+
216
+ if (!existsSync(join(gitDir, "HEAD"))) {
217
+ mkdirSync(gitDir, { recursive: true });
218
+ // `git init <path>` would create <path>/.git (it treats the argument as a
219
+ // work tree), so the repo structure is initialised through GIT_DIR instead.
220
+ const child = spawnSyncGit(["init", "--quiet", "--initial-branch=pi-rewind"], {
221
+ GIT_DIR: gitDir,
222
+ });
223
+ if (child.status !== 0) {
224
+ throw new Error(`git init failed for ${gitDir}: ${child.stderr || `code ${child.status}`}`);
225
+ }
226
+ }
227
+
228
+ // Re-adopted on every open so later edits to the real repo's exclude file
229
+ // are picked up; a missing file leaves any previous copy untouched.
230
+ adoptRealRepoExcludes(absoluteRoot, gitDir);
231
+
232
+ return { root: absoluteRoot, gitDir, metaFile, filesDir, projectKey };
233
+ }
234
+
235
+ function resolveRoot(root: string): string {
236
+ // A normalized absolute path is stable enough for the project key; realpath
237
+ // would add a failure mode on dangling symlinks.
238
+ const absolute = resolvePath(root);
239
+ return absolute.replace(/\/+$/, "") || "/";
240
+ }
241
+
242
+ /**
243
+ * Copy the real repository's info/exclude into the shadow repo so paths the
244
+ * user deliberately keeps out of git are not snapshotted (and therefore not
245
+ * deleted by a restore's `git clean`).
246
+ */
247
+ function adoptRealRepoExcludes(root: string, gitDir: string): void {
248
+ const realExclude = join(root, ".git", "info", "exclude");
249
+ try {
250
+ if (!statSync(realExclude).isFile()) return;
251
+ mkdirSync(join(gitDir, "info"), { recursive: true });
252
+ copyFileSync(realExclude, join(gitDir, "info", "exclude"));
253
+ } catch {
254
+ // No real repo, or nothing to adopt — both fine.
255
+ }
256
+ }
257
+
258
+ /** Small sync git helper used only for `git init` during openStore. */
259
+ function spawnSyncGit(
260
+ args: string[],
261
+ envOverrides: Record<string, string> = {},
262
+ ): { status: number | null; stderr: string } {
263
+ const result = spawnSync("git", args, {
264
+ encoding: "utf8",
265
+ env: { ...process.env, ...envOverrides },
266
+ });
267
+ return { status: result.status, stderr: (result.stderr || "").trim() };
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // metadata
272
+ // ---------------------------------------------------------------------------
273
+
274
+ export function readMeta(store: RewindStore): StoreMeta {
275
+ try {
276
+ const parsed = JSON.parse(readFileSync(store.metaFile, "utf8")) as StoreMeta;
277
+ if (parsed && parsed.version === 1 && parsed.sessions) return parsed;
278
+ } catch {
279
+ // Missing or corrupt metadata: start over. Checkpoint objects stay in the
280
+ // shadow repo but become unreachable and are collected by the next gc.
281
+ }
282
+ return { version: 1, root: store.root, updatedAt: 0, sessions: {} };
283
+ }
284
+
285
+ export function writeMeta(store: RewindStore, meta: StoreMeta): void {
286
+ meta.updatedAt = Date.now();
287
+ mkdirSync(dirname(store.metaFile), { recursive: true });
288
+ writeFileSync(store.metaFile, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
289
+ }
290
+
291
+ export function listCheckpoints(store: RewindStore, sessionId: string): Checkpoint[] {
292
+ const meta = readMeta(store);
293
+ const list = meta.sessions[sessionId] ?? [];
294
+ // Defensive copy, oldest first.
295
+ return [...list].sort((a, b) => a.timestamp - b.timestamp);
296
+ }
297
+
298
+ /** Newest-first view used by the menu. */
299
+ export function listCheckpointsNewestFirst(store: RewindStore, sessionId: string): Checkpoint[] {
300
+ return listCheckpoints(store, sessionId).reverse();
301
+ }
302
+
303
+ function replaceSessionList(
304
+ store: RewindStore,
305
+ sessionId: string,
306
+ mutate: (list: Checkpoint[]) => Checkpoint[],
307
+ ): void {
308
+ const meta = readMeta(store);
309
+ const current = meta.sessions[sessionId] ?? [];
310
+ const next = mutate([...current].sort((a, b) => a.timestamp - b.timestamp));
311
+ if (next.length === 0) delete meta.sessions[sessionId];
312
+ else meta.sessions[sessionId] = next;
313
+ writeMeta(store, meta);
314
+ }
315
+
316
+ /** Attach the resolved user-message entry id to a checkpoint. */
317
+ export function attachEntryId(
318
+ store: RewindStore,
319
+ sessionId: string,
320
+ checkpointId: string,
321
+ entryId: string,
322
+ ): boolean {
323
+ let found = false;
324
+ replaceSessionList(store, sessionId, (list) =>
325
+ list.map((cp) => {
326
+ if (cp.id !== checkpointId || cp.entryId) return cp;
327
+ found = true;
328
+ return { ...cp, entryId };
329
+ }),
330
+ );
331
+ return found;
332
+ }
333
+
334
+ /** Oldest checkpoint of a session that still lacks an entry id. */
335
+ export function findUnresolvedCheckpoint(
336
+ store: RewindStore,
337
+ sessionId: string,
338
+ ): Checkpoint | undefined {
339
+ return listCheckpoints(store, sessionId).find((cp) => cp.kind === "prompt" && !cp.entryId);
340
+ }
341
+
342
+ /** Newest safety checkpoint (the "undo last rewind" target), if any. */
343
+ export function latestSafetyCheckpoint(
344
+ store: RewindStore,
345
+ sessionId: string,
346
+ ): Checkpoint | undefined {
347
+ const safety = listCheckpoints(store, sessionId).filter((cp) => cp.kind === "safety");
348
+ return safety.length > 0 ? safety[safety.length - 1] : undefined;
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // snapshot / checkpoint creation
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * Hash the current worktree into the shadow repo and return its tree sha.
357
+ * Also leaves the shadow index warm, which keeps later snapshots cheap.
358
+ */
359
+ export async function snapshotTree(store: RewindStore): Promise<string> {
360
+ // -A stages modifications, deletions and new files; .gitignore still applies.
361
+ await git(store, ["add", "-A", "--ignore-errors", "--"], { allowFailure: true });
362
+ return git(store, ["write-tree"]);
363
+ }
364
+
365
+ export interface CreateCheckpointOptions {
366
+ sessionId: string;
367
+ kind: CheckpointKind;
368
+ prompt?: string;
369
+ entryId?: string;
370
+ /** Reuse an existing tree sha instead of hashing the worktree again. */
371
+ tree?: string;
372
+ /** Pre-images for files outside the worktree snapshot (safety checkpoints). */
373
+ files?: ExternalFile[];
374
+ timestamp?: number;
375
+ }
376
+
377
+ /**
378
+ * Snapshot the worktree and record a checkpoint.
379
+ *
380
+ * When the worktree is unchanged since the session's previous checkpoint the
381
+ * existing commit is reused (no new object, no new ref) — read-only turns cost
382
+ * nothing but a metadata row.
383
+ */
384
+ export async function createCheckpoint(
385
+ store: RewindStore,
386
+ options: CreateCheckpointOptions,
387
+ ): Promise<Checkpoint> {
388
+ const timestamp = options.timestamp ?? Date.now();
389
+ const tree = options.tree ?? (await snapshotTree(store));
390
+ const previous = listCheckpoints(store, options.sessionId);
391
+ const reusable = previous.find((cp) => cp.tree === tree);
392
+
393
+ let commit: string;
394
+ if (reusable) {
395
+ commit = reusable.commit;
396
+ } else {
397
+ const message = [
398
+ `pi-rewind ${options.kind}`,
399
+ `session ${options.sessionId}`,
400
+ `created ${new Date(timestamp).toISOString()}`,
401
+ options.prompt ? `prompt ${options.prompt.slice(0, 200)}` : null,
402
+ ]
403
+ .filter(Boolean)
404
+ .join("\n");
405
+ commit = await git(store, ["commit-tree", tree], { input: message });
406
+ }
407
+
408
+ const id = `${options.kind}-${timestamp}-${createHash("sha1").update(`${tree}:${timestamp}`).digest("hex").slice(0, 8)}`;
409
+ await git(store, ["update-ref", `${REF_PREFIX}/${id}`, commit]);
410
+
411
+ const checkpoint: Checkpoint = {
412
+ id,
413
+ sessionId: options.sessionId,
414
+ kind: options.kind,
415
+ prompt: options.prompt ?? "",
416
+ ...(options.entryId ? { entryId: options.entryId } : {}),
417
+ ...(options.files && options.files.length > 0 ? { files: options.files } : {}),
418
+ tree,
419
+ commit,
420
+ timestamp,
421
+ };
422
+
423
+ replaceSessionList(store, options.sessionId, (list) => [...list, checkpoint]);
424
+ return checkpoint;
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // pre-images: files the worktree snapshot cannot see
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * A checkpoint only snapshots the worktree under the project root, and
433
+ * `git add -A` skips `.gitignore`d paths. Everything the agent edits elsewhere
434
+ * (`~/.pi/agent/themes/…`, `~/.claude/settings.json`, an ignored `.env`) would
435
+ * therefore be invisible to `/rewind` — its diff would come out empty and the
436
+ * menu would hide the code options entirely (the bug this section fixes).
437
+ *
438
+ * Such files are captured lazily instead: right before an `edit`/`write` tool
439
+ * runs, the bytes on disk are copied into the project's blob store and recorded
440
+ * on the session's newest checkpoint. The first touch inside a turn wins, so
441
+ * the record is the state as it was *before* the turn changed it.
442
+ */
443
+
444
+ /**
445
+ * Is `path` covered by the worktree snapshot (inside the root and not ignored)?
446
+ * Only then can the shadow tree restore it, so only uncovered paths need a
447
+ * pre-image. `git check-ignore` is consulted because it applies exactly the
448
+ * rules `git add -A` uses — the project's `.gitignore` files plus the shadow
449
+ * repo's `info/exclude` (copied from the real repo).
450
+ */
451
+ export function isWorktreeCovered(store: RewindStore, path: string): boolean {
452
+ const rel = relative(store.root, path);
453
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return false;
454
+ const result = spawnSync("git", ["check-ignore", "-q", "--", rel], {
455
+ cwd: store.root,
456
+ env: shadowEnv(store),
457
+ stdio: ["ignore", "ignore", "ignore"],
458
+ });
459
+ // 0 = ignored, 1 = not ignored, anything else = no answer -> treat as covered.
460
+ return result.status !== 0;
461
+ }
462
+
463
+ /** Absolute form of a tool's `path` argument (relative resolves to the cwd). */
464
+ export function resolveTargetPath(store: RewindStore, path: string): string {
465
+ return isAbsolute(path) ? resolvePath(path) : resolvePath(store.root, path);
466
+ }
467
+
468
+ /**
469
+ * Copy `path`'s current bytes into the blob store.
470
+ * Returns the blob name, null when the file does not exist (a pre-image that
471
+ * means "delete it on restore"), or undefined when it is not storable
472
+ * (directory, oversized, unreadable). Content addressing dedupes versions.
473
+ */
474
+ function storeBlob(store: RewindStore, path: string): string | null | undefined {
475
+ try {
476
+ const stat = statSync(path, { throwIfNoEntry: false });
477
+ if (!stat) return null;
478
+ if (!stat.isFile() || stat.size > MAX_EXTERNAL_FILE_BYTES) return undefined;
479
+ const content = readFileSync(path);
480
+ const name = createHash("sha1").update(content).digest("hex");
481
+ const target = join(store.filesDir, name);
482
+ if (!existsSync(target)) {
483
+ mkdirSync(store.filesDir, { recursive: true });
484
+ writeFileSync(target, content);
485
+ }
486
+ return name;
487
+ } catch {
488
+ return undefined;
489
+ }
490
+ }
491
+
492
+ /** Do the bytes on disk still match the pre-image? */
493
+ function blobMatches(store: RewindStore, record: ExternalFile): boolean {
494
+ if (record.blob === null) return !existsSync(record.path);
495
+ try {
496
+ return readFileSync(record.path).equals(readFileSync(join(store.filesDir, record.blob)));
497
+ } catch {
498
+ return false;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Record `path`'s current content as a pre-image on the session's newest
504
+ * checkpoint. Returns true when something was recorded.
505
+ *
506
+ * Called from the `tool_call` handler before the tool runs; the caller must
507
+ * serialize it with `createCheckpoint` (both rewrite the metadata file).
508
+ */
509
+ export function captureExternalFile(
510
+ store: RewindStore,
511
+ sessionId: string,
512
+ path: string,
513
+ ): boolean {
514
+ const absolute = resolveTargetPath(store, path);
515
+ if (isWorktreeCovered(store, absolute)) return false;
516
+
517
+ const meta = readMeta(store);
518
+ const list = meta.sessions[sessionId];
519
+ if (!list || list.length === 0) return false;
520
+ const ordered = [...list].sort((a, b) => a.timestamp - b.timestamp);
521
+ const newest = ordered[ordered.length - 1];
522
+ if (newest.files?.some((file) => file.path === absolute)) return false;
523
+
524
+ const blob = storeBlob(store, absolute);
525
+ if (blob === undefined) return false;
526
+
527
+ meta.sessions[sessionId] = list.map((cp) =>
528
+ cp.id === newest.id
529
+ ? { ...cp, files: [...(cp.files ?? []), { path: absolute, blob }] }
530
+ : cp,
531
+ );
532
+ writeMeta(store, meta);
533
+ return true;
534
+ }
535
+
536
+ /**
537
+ * The restore target for `checkpoints[fromIndex..]` (oldest first): the first
538
+ * pre-image recorded for each path, so a file edited in several turns resolves
539
+ * to how it looked before the earliest of them.
540
+ */
541
+ export function externalRestorePlan(
542
+ checkpoints: Checkpoint[],
543
+ fromIndex = 0,
544
+ ): ExternalFile[] {
545
+ const first = new Map<string, ExternalFile>();
546
+ for (let i = Math.max(0, fromIndex); i < checkpoints.length; i += 1) {
547
+ for (const file of checkpoints[i].files ?? []) {
548
+ if (!first.has(file.path)) first.set(file.path, file);
549
+ }
550
+ }
551
+ return [...first.values()];
552
+ }
553
+
554
+ /** What restoring `records` would change on disk right now. */
555
+ export function diffExternalFiles(
556
+ store: RewindStore,
557
+ records: ExternalFile[],
558
+ ): CheckpointDiff {
559
+ const entries: DiffEntry[] = [];
560
+ for (const record of records) {
561
+ if (record.blob === null) {
562
+ // Not in the checkpoint: a restore removes it.
563
+ if (existsSync(record.path)) entries.push({ status: "A", path: record.path });
564
+ continue;
565
+ }
566
+ if (!existsSync(record.path)) {
567
+ entries.push({ status: "D", path: record.path });
568
+ continue;
569
+ }
570
+ if (!blobMatches(store, record)) entries.push({ status: "M", path: record.path });
571
+ }
572
+ return { entries, empty: entries.length === 0 };
573
+ }
574
+
575
+ /** Pre-images of how these paths look right now (the undo net for a restore). */
576
+ export function captureFileStates(
577
+ store: RewindStore,
578
+ records: ExternalFile[],
579
+ ): ExternalFile[] {
580
+ const captured: ExternalFile[] = [];
581
+ for (const record of records) {
582
+ const blob = storeBlob(store, record.path);
583
+ if (blob === undefined) continue;
584
+ captured.push({ path: record.path, blob });
585
+ }
586
+ return captured;
587
+ }
588
+
589
+ /**
590
+ * Put pre-images back on disk: write the blob's bytes, or delete the file when
591
+ * the checkpoint predates it. Returns the paths that were deleted.
592
+ */
593
+ export async function restoreExternalFiles(
594
+ store: RewindStore,
595
+ records: ExternalFile[],
596
+ ): Promise<string[]> {
597
+ const removed: string[] = [];
598
+ for (const record of records) {
599
+ if (record.blob === null) {
600
+ if (existsSync(record.path)) {
601
+ rmSync(record.path, { force: true });
602
+ removed.push(record.path);
603
+ }
604
+ continue;
605
+ }
606
+ if (blobMatches(store, record)) continue;
607
+ const content = readFileSync(join(store.filesDir, record.blob));
608
+ mkdirSync(dirname(record.path), { recursive: true });
609
+ writeFileSync(record.path, content);
610
+ }
611
+ return removed;
612
+ }
613
+
614
+ /** Drop blobs no checkpoint references any more. */
615
+ export function pruneBlobs(store: RewindStore): number {
616
+ let names: string[];
617
+ try {
618
+ names = readdirSync(store.filesDir);
619
+ } catch {
620
+ return 0;
621
+ }
622
+
623
+ const referenced = new Set<string>();
624
+ const meta = readMeta(store);
625
+ for (const list of Object.values(meta.sessions)) {
626
+ for (const checkpoint of list) {
627
+ for (const file of checkpoint.files ?? []) {
628
+ if (file.blob) referenced.add(file.blob);
629
+ }
630
+ }
631
+ }
632
+
633
+ let removed = 0;
634
+ for (const name of names) {
635
+ if (referenced.has(name)) continue;
636
+ rmSync(join(store.filesDir, name), { force: true });
637
+ removed += 1;
638
+ }
639
+ return removed;
640
+ }
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // diff / restore
644
+ // ---------------------------------------------------------------------------
645
+
646
+ /** File-level difference between two checkpoint trees. */
647
+ export async function diffTrees(
648
+ store: RewindStore,
649
+ fromTree: string,
650
+ toTree: string,
651
+ ): Promise<CheckpointDiff> {
652
+ if (fromTree === toTree) return { entries: [], empty: true };
653
+ const out = await git(store, [
654
+ "diff-tree",
655
+ "-r",
656
+ "--name-status",
657
+ "--no-commit-id",
658
+ "-M",
659
+ fromTree,
660
+ toTree,
661
+ ]);
662
+ if (!out) return { entries: [], empty: true };
663
+
664
+ const entries: DiffEntry[] = [];
665
+ for (const line of out.split("\n")) {
666
+ if (!line.trim()) continue;
667
+ const parts = line.split("\t");
668
+ const status = parts[0] ?? "?";
669
+ // Renames report "R100\told\tnew"; keep the new path.
670
+ const path = parts.length >= 3 ? parts[2] : parts[1] ?? "?";
671
+ entries.push({ status: status.charAt(0), path });
672
+ }
673
+ return { entries, empty: entries.length === 0 };
674
+ }
675
+
676
+ /** Combined file-level difference of two diffs, keeping the first status per path. */
677
+ export function mergeDiffs(first: CheckpointDiff, second: CheckpointDiff): CheckpointDiff {
678
+ const seen = new Set(first.entries.map((entry) => entry.path));
679
+ const entries = [
680
+ ...first.entries,
681
+ ...second.entries.filter((entry) => !seen.has(entry.path)),
682
+ ];
683
+ return { entries, empty: entries.length === 0 };
684
+ }
685
+
686
+ /** Human-readable one-line summary of a diff, e.g. "3 files: +1 ~1 -1". */
687
+ export function summarizeDiff(diff: CheckpointDiff): string {
688
+ if (diff.empty) return "no file changes";
689
+ let added = 0;
690
+ let modified = 0;
691
+ let deleted = 0;
692
+ for (const entry of diff.entries) {
693
+ if (entry.status === "A") added += 1;
694
+ else if (entry.status === "D") deleted += 1;
695
+ else modified += 1;
696
+ }
697
+ const bits: string[] = [];
698
+ if (added) bits.push(`+${added}`);
699
+ if (modified) bits.push(`~${modified}`);
700
+ if (deleted) bits.push(`-${deleted}`);
701
+ return `${diff.entries.length} file${diff.entries.length === 1 ? "" : "s"}: ${bits.join(" ")}`;
702
+ }
703
+
704
+ export interface RestoreOptions {
705
+ /** Pre-images to put back after the worktree reset. Defaults to the
706
+ * checkpoint's own `files` (right for a safety checkpoint, whose records are
707
+ * a snapshot of the state it is meant to restore). A rewind passes the plan
708
+ * built by `externalRestorePlan` for the selected point instead. */
709
+ files?: ExternalFile[];
710
+ }
711
+
712
+ /**
713
+ * Restore the working tree to a checkpoint.
714
+ *
715
+ * Only the working tree changes: the project's own git index/HEAD are never
716
+ * touched, and ignored files (node_modules, build output, …) are never deleted
717
+ * because `git clean` runs without -x.
718
+ */
719
+ export async function restoreCheckpoint(
720
+ store: RewindStore,
721
+ checkpoint: Checkpoint,
722
+ options: RestoreOptions = {},
723
+ ): Promise<RestoreResult> {
724
+ await git(store, ["read-tree", "--reset", "-u", checkpoint.tree]);
725
+ const cleanOutput = await git(store, ["clean", "-fd"], { allowFailure: true });
726
+ const removed: string[] = [];
727
+ for (const line of cleanOutput.split("\n")) {
728
+ const match = /^Removing\s+(.*)$/.exec(line.trim());
729
+ if (match?.[1]) removed.push(match[1]);
730
+ }
731
+ removed.push(...(await restoreExternalFiles(store, options.files ?? checkpoint.files ?? [])));
732
+ return { removed };
733
+ }
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // pruning
737
+ // ---------------------------------------------------------------------------
738
+
739
+ /** Drop a checkpoint's ref and metadata row. */
740
+ export async function deleteCheckpoint(
741
+ store: RewindStore,
742
+ checkpoint: Checkpoint,
743
+ ): Promise<void> {
744
+ await git(store, ["update-ref", "-d", `${REF_PREFIX}/${checkpoint.id}`], {
745
+ allowFailure: true,
746
+ });
747
+ replaceSessionList(store, checkpoint.sessionId, (list) => list.filter((cp) => cp.id !== checkpoint.id));
748
+ pruneBlobs(store);
749
+ }
750
+
751
+ /**
752
+ * Keep at most `max` prompt/session-start checkpoints per session
753
+ * (Claude Code's 100). Safety checkpoints are always kept — they are the undo
754
+ * net for a restore that already happened.
755
+ */
756
+ export async function pruneSession(
757
+ store: RewindStore,
758
+ sessionId: string,
759
+ max = MAX_CHECKPOINTS_PER_SESSION,
760
+ ): Promise<number> {
761
+ const all = listCheckpoints(store, sessionId);
762
+ const prunable = all.filter((cp) => cp.kind !== "safety");
763
+ if (prunable.length <= max) return 0;
764
+
765
+ const victims = prunable.slice(0, prunable.length - max);
766
+ const victimIds = new Set(victims.map((cp) => cp.id));
767
+ for (const victim of victims) {
768
+ await git(store, ["update-ref", "-d", `${REF_PREFIX}/${victim.id}`], { allowFailure: true });
769
+ }
770
+ replaceSessionList(store, sessionId, (list) => list.filter((cp) => !victimIds.has(cp.id)));
771
+ pruneBlobs(store);
772
+ return victims.length;
773
+ }
774
+
775
+ /**
776
+ * Retention sweep: drop every session whose newest checkpoint is older than
777
+ * `days` days, mirroring Claude Code's ~30 day snapshot cleanup.
778
+ */
779
+ export async function pruneStaleSessions(
780
+ store: RewindStore,
781
+ now = Date.now(),
782
+ days = RETENTION_DAYS,
783
+ ): Promise<number> {
784
+ const meta = readMeta(store);
785
+ const cutoff = now - days * 24 * 60 * 60 * 1000;
786
+ let deleted = 0;
787
+
788
+ for (const [sessionId, list] of Object.entries(meta.sessions)) {
789
+ const newest = list.reduce((max, cp) => Math.max(max, cp.timestamp), 0);
790
+ if (newest >= cutoff) continue;
791
+ for (const cp of list) {
792
+ await git(store, ["update-ref", "-d", `${REF_PREFIX}/${cp.id}`], { allowFailure: true });
793
+ deleted += 1;
794
+ }
795
+ delete meta.sessions[sessionId];
796
+ }
797
+
798
+ if (deleted > 0) writeMeta(store, meta);
799
+ pruneBlobs(store);
800
+ return deleted;
801
+ }
802
+
803
+ /** Reclaim objects whose refs were deleted. Best-effort and non-blocking. */
804
+ export function gc(store: RewindStore): Promise<void> {
805
+ return git(store, ["gc", "--quiet", "--prune=now"], { allowFailure: true }).then(() => undefined);
806
+ }
807
+
808
+ /** Remove a whole project's shadow repo (used by tests and manual cleanup). */
809
+ export function removeStore(store: RewindStore): void {
810
+ rmSync(join(store.gitDir, ".."), { recursive: true, force: true });
811
+ }
812
+
813
+ /** Every project directory under the rewind base dir. */
814
+ export function listProjectDirs(agentDir: string): string[] {
815
+ const base = rewindBaseDir(agentDir);
816
+ if (!existsSync(base)) return [];
817
+ return readdirSync(base, { withFileTypes: true })
818
+ .filter((entry) => entry.isDirectory())
819
+ .map((entry) => join(base, entry.name));
820
+ }