@selesai/code 0.9.9 → 0.9.11

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 (306) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/cli/args.js +13 -2
  3. package/dist/cli/args.test.js +8 -0
  4. package/dist/cli/credential-print.js +4 -4
  5. package/dist/cli.js +0 -0
  6. package/dist/core/agent-session-auto-handoff.test.js +24 -1
  7. package/dist/core/agent-session.d.ts +11 -4
  8. package/dist/core/agent-session.js +99 -39
  9. package/dist/core/compaction/branch-summarization.js +30 -30
  10. package/dist/core/compaction/compaction.js +81 -81
  11. package/dist/core/compaction/utils.js +2 -2
  12. package/dist/core/defaults.d.ts +1 -0
  13. package/dist/core/defaults.js +9 -0
  14. package/dist/core/export-html/template.css +1066 -1066
  15. package/dist/core/export-html/template.html +55 -55
  16. package/dist/core/export-html/template.js +1864 -1864
  17. package/dist/core/export-html/vendor/highlight.min.js +1212 -1212
  18. package/dist/core/export-html/vendor/marked.min.js +78 -78
  19. package/dist/core/extensions/loader.js +108 -35
  20. package/dist/core/extensions/loader.test.d.ts +1 -0
  21. package/dist/core/extensions/loader.test.js +21 -0
  22. package/dist/core/extensions/types.d.ts +12 -2
  23. package/dist/core/keybindings.d.ts +2 -2
  24. package/dist/core/messages.js +7 -7
  25. package/dist/core/model-resolver.d.ts +1 -0
  26. package/dist/core/model-resolver.js +10 -4
  27. package/dist/core/package-manager.js +4 -4
  28. package/dist/core/sdk.d.ts +5 -5
  29. package/dist/core/sdk.js +16 -4
  30. package/dist/core/settings-manager.d.ts +6 -0
  31. package/dist/core/settings-manager.js +22 -0
  32. package/dist/core/tools/bash.d.ts +18 -1
  33. package/dist/core/tools/bash.js +41 -23
  34. package/dist/core/tools/index.d.ts +4 -1
  35. package/dist/core/tools/index.js +9 -1
  36. package/dist/core/tools/powershell.d.ts +15 -0
  37. package/dist/core/tools/powershell.js +38 -0
  38. package/dist/core/tools/powershell.test.d.ts +1 -0
  39. package/dist/core/tools/powershell.test.js +20 -0
  40. package/dist/extensions/context-compaction-reminder.test.ts +82 -82
  41. package/dist/extensions/context-compaction-reminder.ts +28 -28
  42. package/dist/extensions/copy-turn.test.ts +24 -1
  43. package/dist/extensions/copy-turn.ts +6 -1
  44. package/dist/extensions/handoff-new.test.ts +15 -2
  45. package/dist/extensions/handoff-new.ts +44 -30
  46. package/dist/extensions/pi-intercom/LICENSE +21 -21
  47. package/dist/extensions/pi-intercom/broker/client.test.ts +83 -83
  48. package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -387
  49. package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -114
  50. package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -153
  51. package/dist/extensions/pi-intercom/broker/paths.ts +134 -134
  52. package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -34
  53. package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -21
  54. package/dist/extensions/pi-intercom/cwd.test.ts +40 -40
  55. package/dist/extensions/pi-intercom/cwd.ts +31 -31
  56. package/dist/extensions/pi-intercom/extension-api.ts +44 -44
  57. package/dist/extensions/pi-intercom/format-context.test.ts +31 -31
  58. package/dist/extensions/pi-intercom/format-context.ts +32 -32
  59. package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -66
  60. package/dist/extensions/pi-intercom/ui/compose.ts +143 -143
  61. package/dist/extensions/pi-intercom/ui/session-list.ts +166 -166
  62. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +286 -286
  63. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +108 -108
  64. package/dist/extensions/pi-powerline-footer/separators.ts +57 -57
  65. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +47 -47
  66. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -39
  67. package/dist/extensions/pi-powerline-footer/theme.example.json +24 -24
  68. package/dist/extensions/pi-powerline-footer/theme.json +12 -12
  69. package/dist/extensions/pi-powerline-footer/tps.ts +345 -345
  70. package/dist/extensions/pi-rewind-hook/README.md +245 -245
  71. package/dist/extensions/pi-rewind-hook/index.ts +1445 -1445
  72. package/dist/extensions/pi-rewind-hook/package.json +29 -29
  73. package/dist/extensions/pi-subagents/install.mjs +0 -0
  74. package/dist/extensions/pi-web-agent/package.json +31 -31
  75. package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -205
  76. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -136
  77. package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -152
  78. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -25
  79. package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -28
  80. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +136 -136
  81. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -946
  82. package/dist/extensions/pi-web-agent/src/extension.ts +126 -126
  83. package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -118
  84. package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -199
  85. package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -100
  86. package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -117
  87. package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -67
  88. package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -60
  89. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -50
  90. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -52
  91. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -105
  92. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -45
  93. package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -28
  94. package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -47
  95. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -376
  96. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -64
  97. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -181
  98. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -101
  99. package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -81
  100. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +210 -210
  101. package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -75
  102. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -61
  103. package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -54
  104. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -41
  105. package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -20
  106. package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -63
  107. package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -114
  108. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -72
  109. package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -96
  110. package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -71
  111. package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -31
  112. package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -31
  113. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -157
  114. package/dist/extensions/pi-web-agent/src/types.ts +88 -88
  115. package/dist/extensions/ponytail/package.json +8 -8
  116. package/dist/extensions/question/batch.ts +103 -103
  117. package/dist/extensions/question/constants.ts +30 -30
  118. package/dist/extensions/question/helpers.ts +58 -58
  119. package/dist/extensions/question/navigation.ts +14 -14
  120. package/dist/extensions/question/package.json +19 -19
  121. package/dist/extensions/question/schemas.ts +43 -43
  122. package/dist/extensions/question/selection-mode.ts +46 -46
  123. package/dist/extensions/question/shortcuts.ts +44 -44
  124. package/dist/extensions/question/types.ts +132 -132
  125. package/dist/extensions/test-resolve-hook-impl.mjs +6 -6
  126. package/dist/extensions/test-resolve-hook.mjs +6 -6
  127. package/dist/extensions/web-agent-onboarding.ts +222 -222
  128. package/dist/extensions/workflow/package.json +17 -17
  129. package/dist/package-manager-cli.js +71 -71
  130. package/dist/rpc-entry.js +0 -0
  131. package/dist/skills/agent-browser/SKILL.md +52 -52
  132. package/dist/skills/batch-grill-me/SKILL.md +19 -19
  133. package/dist/skills/grill-me/SKILL.md +10 -10
  134. package/dist/skills/handoff/SKILL.md +16 -16
  135. package/dist/skills/handoff-text/SKILL.md +14 -14
  136. package/dist/skills/implanger/SKILL.md +69 -69
  137. package/dist/skills/improve-codebase/REFERENCE.md +78 -78
  138. package/dist/skills/improve-codebase/SKILL.md +178 -178
  139. package/dist/skills/planger/SKILL.md +166 -166
  140. package/dist/skills/ponytail/SKILL.md +116 -116
  141. package/dist/skills/ponytail-audit/SKILL.md +42 -42
  142. package/dist/skills/ponytail-debt/SKILL.md +45 -45
  143. package/dist/skills/ponytail-gain/SKILL.md +51 -51
  144. package/dist/skills/ponytail-help/SKILL.md +70 -70
  145. package/dist/skills/ponytail-review/SKILL.md +58 -58
  146. package/dist/skills/selesai-handoff/SKILL.md +20 -20
  147. package/dist/skills/workflow-creation/SKILL.md +73 -73
  148. package/dist/themes/powerline-footer/theme.json +33 -33
  149. package/dist/utils/shell.d.ts +3 -0
  150. package/dist/utils/shell.js +17 -5
  151. package/docs/compaction.md +396 -396
  152. package/docs/containerization.md +111 -111
  153. package/docs/development.md +71 -71
  154. package/docs/docs.json +164 -164
  155. package/docs/environment-variables.md +86 -86
  156. package/docs/index.md +83 -83
  157. package/docs/json.md +82 -82
  158. package/docs/models.md +502 -502
  159. package/docs/packages.md +227 -227
  160. package/docs/plans/subagent-delegation/phase-0-correctness.md +264 -264
  161. package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +485 -485
  162. package/docs/plans/subagent-delegation/phase-2-context-controls.md +281 -281
  163. package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +361 -361
  164. package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +380 -380
  165. package/docs/prompt-templates.md +95 -95
  166. package/docs/providers.md +293 -293
  167. package/docs/sdk.md +1144 -1143
  168. package/docs/security.md +59 -59
  169. package/docs/session-format.md +414 -414
  170. package/docs/sessions.md +145 -145
  171. package/docs/shared-host-extensions.md +109 -109
  172. package/docs/shell-aliases.md +13 -13
  173. package/docs/skills.md +231 -231
  174. package/docs/terminal-setup.md +142 -142
  175. package/docs/termux.md +127 -127
  176. package/docs/themes.md +295 -295
  177. package/docs/tmux.md +63 -63
  178. package/docs/tui.md +927 -927
  179. package/docs/windows.md +17 -17
  180. package/examples/README.md +25 -25
  181. package/examples/extensions/README.md +211 -211
  182. package/examples/extensions/auto-commit-on-exit.ts +49 -49
  183. package/examples/extensions/bash-spawn-hook.ts +30 -30
  184. package/examples/extensions/bookmark.ts +50 -50
  185. package/examples/extensions/border-status-editor.ts +150 -150
  186. package/examples/extensions/built-in-tool-renderer.ts +249 -249
  187. package/examples/extensions/claude-rules.ts +86 -86
  188. package/examples/extensions/commands.ts +72 -72
  189. package/examples/extensions/confirm-destructive.ts +59 -59
  190. package/examples/extensions/custom-compaction.ts +130 -130
  191. package/examples/extensions/custom-footer.ts +64 -64
  192. package/examples/extensions/custom-header.ts +73 -73
  193. package/examples/extensions/custom-provider-anthropic/index.ts +610 -610
  194. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -24
  195. package/examples/extensions/custom-provider-anthropic/package.json +19 -19
  196. package/examples/extensions/custom-provider-gitlab-duo/index.ts +404 -404
  197. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -16
  198. package/examples/extensions/custom-provider-gitlab-duo/test.ts +82 -82
  199. package/examples/extensions/dirty-repo-guard.ts +56 -56
  200. package/examples/extensions/doom-overlay/README.md +46 -46
  201. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -21
  202. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  203. package/examples/extensions/doom-overlay/doom/build.sh +152 -152
  204. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -72
  205. package/examples/extensions/doom-overlay/doom-component.ts +132 -132
  206. package/examples/extensions/doom-overlay/doom-engine.ts +173 -173
  207. package/examples/extensions/doom-overlay/doom-keys.ts +104 -104
  208. package/examples/extensions/doom-overlay/index.ts +74 -74
  209. package/examples/extensions/doom-overlay/wad-finder.ts +51 -51
  210. package/examples/extensions/dynamic-resources/SKILL.md +8 -8
  211. package/examples/extensions/dynamic-resources/dynamic.json +79 -79
  212. package/examples/extensions/dynamic-resources/dynamic.md +5 -5
  213. package/examples/extensions/dynamic-resources/index.ts +15 -15
  214. package/examples/extensions/dynamic-tools.ts +74 -74
  215. package/examples/extensions/event-bus.ts +43 -43
  216. package/examples/extensions/file-trigger.ts +41 -41
  217. package/examples/extensions/git-checkpoint.ts +53 -53
  218. package/examples/extensions/git-merge-and-resolve.ts +115 -115
  219. package/examples/extensions/github-issue-autocomplete.ts +185 -185
  220. package/examples/extensions/gondolin/index.ts +531 -531
  221. package/examples/extensions/gondolin/package-lock.json +185 -185
  222. package/examples/extensions/gondolin/package.json +19 -19
  223. package/examples/extensions/handoff.ts +199 -199
  224. package/examples/extensions/hello.ts +26 -26
  225. package/examples/extensions/hidden-thinking-label.ts +53 -53
  226. package/examples/extensions/inline-bash.ts +94 -94
  227. package/examples/extensions/input-transform-streaming.ts +39 -39
  228. package/examples/extensions/input-transform.ts +43 -43
  229. package/examples/extensions/interactive-shell.ts +196 -196
  230. package/examples/extensions/mac-system-theme.ts +47 -47
  231. package/examples/extensions/message-renderer.ts +59 -59
  232. package/examples/extensions/minimal-mode.ts +426 -426
  233. package/examples/extensions/modal-editor.ts +85 -85
  234. package/examples/extensions/model-status.ts +31 -31
  235. package/examples/extensions/notify.ts +55 -55
  236. package/examples/extensions/overlay-qa-tests.ts +1450 -1450
  237. package/examples/extensions/overlay-test.ts +153 -153
  238. package/examples/extensions/permission-gate.ts +34 -34
  239. package/examples/extensions/pirate.ts +47 -47
  240. package/examples/extensions/plan-mode/README.md +66 -66
  241. package/examples/extensions/plan-mode/index.ts +390 -390
  242. package/examples/extensions/plan-mode/utils.ts +168 -168
  243. package/examples/extensions/preset.ts +436 -436
  244. package/examples/extensions/project-trust.ts +64 -64
  245. package/examples/extensions/prompt-customizer.ts +97 -97
  246. package/examples/extensions/protected-paths.ts +30 -30
  247. package/examples/extensions/provider-payload.ts +18 -18
  248. package/examples/extensions/qna.ts +122 -122
  249. package/examples/extensions/question.ts +285 -285
  250. package/examples/extensions/questionnaire.ts +448 -448
  251. package/examples/extensions/rainbow-editor.ts +88 -88
  252. package/examples/extensions/reload-runtime.ts +37 -37
  253. package/examples/extensions/rpc-demo.ts +118 -118
  254. package/examples/extensions/sandbox/index.ts +321 -321
  255. package/examples/extensions/sandbox/package-lock.json +92 -92
  256. package/examples/extensions/sandbox/package.json +19 -19
  257. package/examples/extensions/send-user-message.ts +97 -97
  258. package/examples/extensions/session-name.ts +27 -27
  259. package/examples/extensions/shutdown-command.ts +63 -63
  260. package/examples/extensions/snake.ts +343 -343
  261. package/examples/extensions/space-invaders.ts +560 -560
  262. package/examples/extensions/ssh.ts +220 -220
  263. package/examples/extensions/status-line.ts +32 -32
  264. package/examples/extensions/structured-output.ts +65 -65
  265. package/examples/extensions/subagent/README.md +175 -175
  266. package/examples/extensions/subagent/agents/planner.md +37 -37
  267. package/examples/extensions/subagent/agents/reviewer.md +35 -35
  268. package/examples/extensions/subagent/agents/scout.md +50 -50
  269. package/examples/extensions/subagent/agents/worker.md +24 -24
  270. package/examples/extensions/subagent/agents.ts +126 -126
  271. package/examples/extensions/subagent/index.ts +1015 -1015
  272. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -10
  273. package/examples/extensions/subagent/prompts/implement.md +10 -10
  274. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -9
  275. package/examples/extensions/summarize.ts +209 -209
  276. package/examples/extensions/system-prompt-header.ts +17 -17
  277. package/examples/extensions/tic-tac-toe.ts +1008 -1008
  278. package/examples/extensions/timed-confirm.ts +70 -70
  279. package/examples/extensions/titlebar-spinner.ts +58 -58
  280. package/examples/extensions/todo.ts +297 -297
  281. package/examples/extensions/tool-override.ts +144 -144
  282. package/examples/extensions/tools.ts +146 -146
  283. package/examples/extensions/trigger-compact.ts +50 -50
  284. package/examples/extensions/truncated-tool.ts +195 -195
  285. package/examples/extensions/widget-placement.ts +9 -9
  286. package/examples/extensions/with-deps/index.ts +32 -32
  287. package/examples/extensions/with-deps/package-lock.json +31 -31
  288. package/examples/extensions/with-deps/package.json +22 -22
  289. package/examples/extensions/working-indicator.ts +123 -123
  290. package/examples/extensions/working-message-test.ts +25 -25
  291. package/examples/rpc-extension-ui.ts +632 -632
  292. package/examples/sdk/01-minimal.ts +26 -26
  293. package/examples/sdk/02-custom-model.ts +53 -53
  294. package/examples/sdk/03-custom-prompt.ts +75 -75
  295. package/examples/sdk/04-skills.ts +55 -55
  296. package/examples/sdk/05-tools.ts +48 -48
  297. package/examples/sdk/06-extensions.ts +99 -99
  298. package/examples/sdk/07-context-files.ts +47 -47
  299. package/examples/sdk/08-prompt-templates.ts +51 -51
  300. package/examples/sdk/09-api-keys-and-oauth.ts +52 -52
  301. package/examples/sdk/10-settings.ts +53 -53
  302. package/examples/sdk/11-sessions.ts +52 -52
  303. package/examples/sdk/12-full-control.ts +79 -79
  304. package/examples/sdk/13-session-runtime.ts +67 -67
  305. package/examples/sdk/README.md +144 -144
  306. package/package.json +4 -4
@@ -1,1445 +1,1445 @@
1
- import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@selesai/code";
2
- import { exec as execCb } from "child_process";
3
- import { existsSync, readFileSync, realpathSync } from "fs";
4
- import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
5
- import { tmpdir } from "os";
6
- import { dirname, isAbsolute, join, relative, resolve } from "path";
7
- import { promisify } from "util";
8
-
9
- const execAsync = promisify(execCb);
10
-
11
- const STORE_REF = "refs/pi-rewind/store";
12
- const STATUS_KEY = "rewind";
13
- const FORK_PREFERENCE_SOURCE_ALLOWLIST = new Set(["fork-from-first"]);
14
- const CHECKPOINT_SOURCE_ALLOWLIST = new Set(["pi-custom-compaction"]);
15
- const LEGACY_ZERO_SHA = "0000000000000000000000000000000000000000";
16
- const RETENTION_SWEEP_THRESHOLD = 50;
17
- const RETENTION_VERSION = 2;
18
- const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
19
-
20
- type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
21
-
22
- type GitExecResult = Awaited<ReturnType<ExecFn>>;
23
-
24
- type BindingTuple = [entryId: string, snapshotIndex: number];
25
-
26
- interface RewindSettings {
27
- rewind?: {
28
- silentCheckpoints?: boolean;
29
- retention?: {
30
- maxSnapshots?: number;
31
- maxAgeDays?: number;
32
- pinLabeledEntries?: boolean;
33
- scanMode?: "ancestor-only" | "repo-sessions";
34
- startupBudgetMs?: number;
35
- };
36
- };
37
- }
38
-
39
- type RewindRetentionSettings = NonNullable<NonNullable<RewindSettings["rewind"]>["retention"]>;
40
-
41
- interface RewindTurnData {
42
- v: 2;
43
- snapshots: string[];
44
- bindings: BindingTuple[];
45
- }
46
-
47
- interface RewindOpData {
48
- v: 2;
49
- snapshots: string[];
50
- bindings?: BindingTuple[];
51
- current?: number;
52
- undo?: number;
53
- }
54
-
55
- interface RewindForkPendingData {
56
- v: 2;
57
- current: string;
58
- undo?: string;
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
- latestForkPending?: RewindForkPendingData;
102
- }
103
-
104
- interface SessionLikeMessageEntry {
105
- type: "message";
106
- id: string;
107
- parentId: string | null;
108
- timestamp: string;
109
- message: {
110
- role: string;
111
- timestamp?: number;
112
- content?: unknown;
113
- };
114
- }
115
-
116
- interface SessionLikeCustomEntry {
117
- type: "custom";
118
- id: string;
119
- parentId: string | null;
120
- timestamp: string;
121
- customType: string;
122
- data?: unknown;
123
- }
124
-
125
- interface SessionLikeLabelEntry {
126
- type: "label";
127
- targetId: string;
128
- label?: string;
129
- }
130
-
131
- interface SessionLikeBranchSummaryEntry {
132
- type: "branch_summary";
133
- id: string;
134
- }
135
-
136
- interface SessionLikeGenericEntry {
137
- type: string;
138
- id?: string;
139
- parentId?: string | null;
140
- timestamp?: string;
141
- message?: {
142
- role?: string;
143
- timestamp?: number;
144
- content?: unknown;
145
- };
146
- customType?: string;
147
- data?: unknown;
148
- targetId?: string;
149
- label?: string;
150
- }
151
-
152
- type SessionLikeEntry =
153
- | SessionLikeMessageEntry
154
- | SessionLikeCustomEntry
155
- | SessionLikeLabelEntry
156
- | SessionLikeBranchSummaryEntry
157
- | SessionLikeGenericEntry;
158
-
159
- let cachedSettings: RewindSettings | null = null;
160
-
161
- function getSettingsFilePath(): string {
162
- return join(getAgentDir(), "settings.json");
163
- }
164
-
165
- function getDefaultSessionsDir(): string {
166
- return join(getAgentDir(), "sessions");
167
- }
168
-
169
- function getSettings(): RewindSettings {
170
- if (cachedSettings) {
171
- return cachedSettings;
172
- }
173
-
174
- try {
175
- cachedSettings = JSON.parse(readFileSync(getSettingsFilePath(), "utf-8")) as RewindSettings;
176
- } catch {
177
- // Invalid/missing settings must not disable rewind; fall back to defaults.
178
- cachedSettings = {};
179
- }
180
-
181
- return cachedSettings;
182
- }
183
-
184
- function getSilentCheckpointsSetting(): boolean {
185
- return getSettings().rewind?.silentCheckpoints === true;
186
- }
187
-
188
- function getRetentionSettings(): RewindRetentionSettings | undefined {
189
- return getSettings().rewind?.retention;
190
- }
191
-
192
- function getRetentionScanMode(): "ancestor-only" | "repo-sessions" {
193
- return getRetentionSettings()?.scanMode === "repo-sessions" ? "repo-sessions" : "ancestor-only";
194
- }
195
-
196
- function getStartupSweepBudgetMs(): number | undefined {
197
- const value = getRetentionSettings()?.startupBudgetMs;
198
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
199
- return undefined;
200
- }
201
- return value;
202
- }
203
-
204
- function isRewindTurnData(value: unknown): value is RewindTurnData {
205
- if (!value || typeof value !== "object") return false;
206
- const data = value as Partial<RewindTurnData>;
207
- return data.v === 2 && Array.isArray(data.snapshots) && Array.isArray(data.bindings);
208
- }
209
-
210
- function isRewindOpData(value: unknown): value is RewindOpData {
211
- if (!value || typeof value !== "object") return false;
212
- const data = value as Partial<RewindOpData>;
213
- return data.v === 2 && Array.isArray(data.snapshots);
214
- }
215
-
216
- function isRewindForkPendingData(value: unknown): value is RewindForkPendingData {
217
- if (!value || typeof value !== "object") return false;
218
- const data = value as Partial<RewindForkPendingData>;
219
- return data.v === 2 && typeof data.current === "string" && data.current.length > 0;
220
- }
221
-
222
- function canonicalizePath(value: string): string {
223
- const resolvedValue = resolve(value);
224
- try {
225
- return realpathSync.native(resolvedValue);
226
- } catch {
227
- // Path may not exist yet; compare with the resolved path directly.
228
- return resolvedValue;
229
- }
230
- }
231
-
232
- function isInsidePath(targetPath: string, parentPath: string): boolean {
233
- const resolvedTarget = canonicalizePath(targetPath);
234
- const resolvedParent = canonicalizePath(parentPath);
235
- const rel = relative(resolvedParent, resolvedTarget);
236
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
237
- }
238
-
239
- function toTimestamp(value: string | undefined): number {
240
- if (!value) return 0;
241
- const parsed = Date.parse(value);
242
- return Number.isFinite(parsed) ? parsed : 0;
243
- }
244
-
245
- function getTextContent(content: unknown): string {
246
- if (typeof content === "string") return content;
247
- if (!Array.isArray(content)) return "";
248
- return content
249
- .filter((block): block is { type: string; text?: string } => !!block && typeof block === "object")
250
- .filter((block) => block.type === "text")
251
- .map((block) => block.text ?? "")
252
- .join("\n");
253
- }
254
-
255
- function updateLabelSet(labelIds: Set<string>, entry: SessionLikeLabelEntry) {
256
- if (!entry.targetId) return;
257
- if (entry.label && entry.label.trim()) {
258
- labelIds.add(entry.targetId);
259
- return;
260
- }
261
- labelIds.delete(entry.targetId);
262
- }
263
-
264
- function applyBindings(target: Map<string, string>, snapshots: string[], bindings?: BindingTuple[]) {
265
- if (!bindings) return;
266
- for (const [entryId, snapshotIndex] of bindings) {
267
- const commitSha = snapshots[snapshotIndex];
268
- if (entryId && commitSha) {
269
- target.set(entryId, commitSha);
270
- }
271
- }
272
- }
273
-
274
- function addReferences(target: ParsedLedgerReference[], snapshots: string[], timestamp: number, data: RewindTurnData | RewindOpData) {
275
- if ("bindings" in data && data.bindings) {
276
- for (const [entryId, snapshotIndex] of data.bindings) {
277
- const commitSha = snapshots[snapshotIndex];
278
- if (!commitSha) continue;
279
- target.push({ commitSha, entryId, timestamp, kind: "binding" });
280
- }
281
- }
282
-
283
- if ("current" in data && typeof data.current === "number") {
284
- const commitSha = snapshots[data.current];
285
- if (commitSha) {
286
- target.push({ commitSha, timestamp, kind: "current" });
287
- }
288
- }
289
-
290
- if ("undo" in data && typeof data.undo === "number") {
291
- const commitSha = snapshots[data.undo];
292
- if (commitSha) {
293
- target.push({ commitSha, timestamp, kind: "undo" });
294
- }
295
- }
296
- }
297
-
298
- function resolveBindingSnapshotIndex(snapshots: string[], commitSha: string): number {
299
- const existingIndex = snapshots.indexOf(commitSha);
300
- if (existingIndex >= 0) return existingIndex;
301
- snapshots.push(commitSha);
302
- return snapshots.length - 1;
303
- }
304
-
305
- function addBindingToCollector(collector: ActivePromptCollector, entryId: string, commitSha: string) {
306
- const snapshotIndex = resolveBindingSnapshotIndex(collector.snapshots, commitSha);
307
- collector.bindings.push([entryId, snapshotIndex]);
308
- }
309
-
310
- function getCommitFromData(data: RewindOpData, indexKey: "current" | "undo"): string | undefined {
311
- const snapshotIndex = data[indexKey];
312
- return typeof snapshotIndex === "number" ? data.snapshots[snapshotIndex] : undefined;
313
- }
314
-
315
- function isRestorableTreeEntry(entry: SessionLikeEntry | undefined): boolean {
316
- if (!entry) return false;
317
- if (entry.type === "message") {
318
- return entry.message.role === "user" || entry.message.role === "assistant";
319
- }
320
- return entry.type === "branch_summary" || entry.type === "compaction" || entry.type === "custom_message";
321
- }
322
-
323
- function isAssistantMessageEntry(entry: SessionLikeEntry): entry is SessionLikeMessageEntry {
324
- return entry.type === "message" && entry.message.role === "assistant";
325
- }
326
-
327
- function findLatestUserMessageEntry(entries: SessionLikeEntry[]): SessionLikeMessageEntry | null {
328
- for (let index = entries.length - 1; index >= 0; index -= 1) {
329
- const entry = entries[index];
330
- if (entry.type === "message" && entry.message.role === "user") {
331
- return entry;
332
- }
333
- }
334
- return null;
335
- }
336
-
337
- function findLatestMatchingUserMessageEntry(
338
- entries: SessionLikeEntry[],
339
- promptText: string | null | undefined,
340
- ): SessionLikeMessageEntry | null {
341
- if (!promptText) return null;
342
-
343
- for (let index = entries.length - 1; index >= 0; index -= 1) {
344
- const entry = entries[index];
345
- if (entry.type !== "message" || entry.message.role !== "user") continue;
346
- if (getTextContent(entry.message.content) === promptText) {
347
- return entry;
348
- }
349
- }
350
-
351
- return null;
352
- }
353
-
354
- function findAssistantEntryForTurn(entries: SessionLikeEntry[], message: { timestamp?: number; content?: unknown }): SessionLikeMessageEntry | null {
355
- const targetTimestamp = message.timestamp;
356
- const targetText = getTextContent(message.content);
357
-
358
- for (let index = entries.length - 1; index >= 0; index -= 1) {
359
- const entry = entries[index];
360
- if (!isAssistantMessageEntry(entry)) continue;
361
-
362
- if (targetTimestamp !== undefined && entry.message.timestamp === targetTimestamp) {
363
- return entry;
364
- }
365
-
366
- if (targetText && getTextContent(entry.message.content) === targetText) {
367
- return entry;
368
- }
369
- }
370
-
371
- return null;
372
- }
373
-
374
- export default function rewindExtension(pi: ExtensionAPI) {
375
- const entryToCommit = new Map<string, string>();
376
- const parsedSessionCache = new Map<string, { mtimeMs: number; ledger: ParsedSessionLedger }>();
377
-
378
- let repoRoot: string | null = null;
379
- let sessionId: string | null = null;
380
- let currentSessionFile: string | undefined;
381
- let currentParentSession: string | undefined;
382
- let currentSessionCwd: string | undefined;
383
- let isGitRepo = false;
384
- let lastExact: ExactState | null = null;
385
- let activeBranchState: ActiveBranchState = {};
386
- let promptCollector: ActivePromptCollector | null = null;
387
- let pendingTreeState: PendingResultingState | null = null;
388
- let activePromptText: string | null = null;
389
- let newSnapshotsSinceSweep = 0;
390
- let sweepRunning = false;
391
- let sweepCompletedThisSession = false;
392
- let forceConversationOnlyOnNextFork = false;
393
- let forceConversationOnlySource: string | null = null;
394
-
395
- function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info") {
396
- if (!ctx.hasUI) return;
397
- if (level === "info" && getSilentCheckpointsSetting()) return;
398
- ctx.ui.notify(message, level);
399
- }
400
-
401
- function updateStatus(ctx: ExtensionContext) {
402
- if (!ctx.hasUI) return;
403
- if (!isGitRepo || getSilentCheckpointsSetting()) {
404
- ctx.ui.setStatus(STATUS_KEY, undefined);
405
- return;
406
- }
407
-
408
- const uniqueSnapshots = new Set(entryToCommit.values()).size;
409
- const theme = ctx.ui.theme;
410
- ctx.ui.setStatus(
411
- STATUS_KEY,
412
- theme.fg("dim", "◆ ") + theme.fg("muted", `${entryToCommit.size} points / ${uniqueSnapshots} snapshots`),
413
- );
414
- }
415
-
416
- function resetState() {
417
- entryToCommit.clear();
418
- parsedSessionCache.clear();
419
- repoRoot = null;
420
- sessionId = null;
421
- currentSessionFile = undefined;
422
- currentParentSession = undefined;
423
- currentSessionCwd = undefined;
424
- isGitRepo = false;
425
- lastExact = null;
426
- activeBranchState = {};
427
- promptCollector = null;
428
- pendingTreeState = null;
429
- activePromptText = null;
430
- newSnapshotsSinceSweep = 0;
431
- sweepCompletedThisSession = false;
432
- forceConversationOnlyOnNextFork = false;
433
- forceConversationOnlySource = null;
434
- cachedSettings = null;
435
- }
436
-
437
- function syncSessionIdentity(ctx: ExtensionContext) {
438
- sessionId = ctx.sessionManager.getSessionId();
439
- currentSessionFile = ctx.sessionManager.getSessionFile();
440
- currentParentSession = ctx.sessionManager.getHeader()?.parentSession;
441
- currentSessionCwd = ctx.sessionManager.getCwd();
442
- }
443
-
444
- async function execGitChecked(args: string[]): Promise<GitExecResult> {
445
- const result = await pi.exec("git", args);
446
- if (result.code !== 0) {
447
- const stderr = result.stderr.trim();
448
- throw new Error(stderr || `git ${args.join(" ")} failed with code ${result.code}`);
449
- }
450
- return result;
451
- }
452
-
453
- async function getRepoRoot(exec: ExecFn): Promise<string> {
454
- if (repoRoot) return repoRoot;
455
- const result = await exec("git", ["rev-parse", "--show-toplevel"]);
456
- if (result.code !== 0) {
457
- const stderr = result.stderr.trim();
458
- throw new Error(stderr || `git rev-parse --show-toplevel failed with code ${result.code}`);
459
- }
460
- repoRoot = result.stdout.trim();
461
- return repoRoot;
462
- }
463
-
464
- async function captureWorktreeTree(): Promise<{ treeSha: string }> {
465
- const root = await getRepoRoot(pi.exec);
466
- const tempDir = await mkdtemp(join(tmpdir(), "pi-rewind-"));
467
- const tempIndex = join(tempDir, "index");
468
-
469
- try {
470
- const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
471
- await execAsync("git add -A", { cwd: root, env });
472
- const { stdout } = await execAsync("git write-tree", { cwd: root, env });
473
- return { treeSha: stdout.trim() };
474
- } finally {
475
- await rm(tempDir, { recursive: true, force: true }).catch(() => {
476
- // Best effort cleanup for temporary index directory.
477
- });
478
- }
479
- }
480
-
481
- async function getCommitTreeSha(commitSha: string): Promise<string> {
482
- const result = await execGitChecked(["show", "-s", "--format=%T", commitSha]);
483
- return result.stdout.trim();
484
- }
485
-
486
- async function commitExists(commitSha: string): Promise<boolean> {
487
- const result = await pi.exec("git", ["cat-file", "-e", `${commitSha}^{commit}`]);
488
- return result.code === 0;
489
- }
490
-
491
- async function getStoreHead(): Promise<string | undefined> {
492
- const result = await pi.exec("git", ["rev-parse", "--verify", STORE_REF]);
493
- if (result.code !== 0) {
494
- return undefined;
495
- }
496
- const value = result.stdout.trim();
497
- return value || undefined;
498
- }
499
-
500
- async function createStoreKeepaliveCommit(snapshotCommitSha: string, previousStoreHead?: string): Promise<string> {
501
- const args = ["commit-tree", EMPTY_TREE_SHA];
502
-
503
- if (previousStoreHead) {
504
- args.push("-p", previousStoreHead);
505
- }
506
-
507
- args.push("-p", snapshotCommitSha, "-m", "pi rewind store");
508
- const result = await execGitChecked(args);
509
- return result.stdout.trim();
510
- }
511
-
512
- async function appendSnapshotToStore(commitSha: string): Promise<void> {
513
- let attempts = 0;
514
- let lastError: unknown;
515
-
516
- while (attempts < 5) {
517
- attempts += 1;
518
- const oldHead = await getStoreHead();
519
- const keepaliveCommit = await createStoreKeepaliveCommit(commitSha, oldHead);
520
-
521
- try {
522
- if (oldHead) {
523
- await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, oldHead]);
524
- } else {
525
- await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, LEGACY_ZERO_SHA]);
526
- }
527
- return;
528
- } catch (error) {
529
- // Retry if another process updated the store ref concurrently.
530
- // Keep the most recent error for actionable failure context.
531
- lastError = error;
532
- }
533
- }
534
-
535
- const detail = lastError instanceof Error ? lastError.message : String(lastError);
536
- throw new Error(`failed to update rewind store ref: ${detail}`);
537
- }
538
-
539
- async function rewriteStoreToLiveSet(liveCommitShas: string[]): Promise<"rewritten" | "preserved-empty"> {
540
- const uniqueLiveCommits = [...new Set(liveCommitShas.filter(Boolean))];
541
- if (uniqueLiveCommits.length === 0) {
542
- return "preserved-empty";
543
- }
544
-
545
- let head: string | undefined;
546
- for (const commitSha of uniqueLiveCommits) {
547
- head = await createStoreKeepaliveCommit(commitSha, head);
548
- }
549
-
550
- const oldHead = await getStoreHead();
551
- if (oldHead) {
552
- await execGitChecked(["update-ref", STORE_REF, head!, oldHead]);
553
- return "rewritten";
554
- }
555
-
556
- await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]);
557
- return "rewritten";
558
- }
559
-
560
- async function ensureSnapshotForTree(treeSha: string): Promise<string> {
561
- if (lastExact && lastExact.treeSha === treeSha) {
562
- return lastExact.commitSha;
563
- }
564
-
565
- const result = await execGitChecked(["commit-tree", treeSha, "-m", "pi rewind snapshot"]);
566
- const commitSha = result.stdout.trim();
567
- await appendSnapshotToStore(commitSha);
568
- lastExact = { commitSha, treeSha };
569
- newSnapshotsSinceSweep += 1;
570
- return commitSha;
571
- }
572
-
573
- async function ensureSnapshotForCurrentWorktree(): Promise<string> {
574
- const { treeSha } = await captureWorktreeTree();
575
- return ensureSnapshotForTree(treeSha);
576
- }
577
-
578
- async function deletePathsFromWorkingTree(paths: string[]) {
579
- if (paths.length === 0) return;
580
- const root = await getRepoRoot(pi.exec);
581
-
582
- for (const repoRelativePath of paths) {
583
- const absolutePath = resolve(root, repoRelativePath);
584
- if (!isInsidePath(absolutePath, root)) {
585
- throw new Error(`refusing to delete path outside repo root: ${repoRelativePath}`);
586
- }
587
- await rm(absolutePath, { recursive: true, force: true });
588
- }
589
- }
590
-
591
- async function getDeletedPaths(currentTreeSha: string, targetTreeSha: string): Promise<string[]> {
592
- const result = await execGitChecked([
593
- "diff",
594
- "--name-only",
595
- "--diff-filter=D",
596
- "-z",
597
- currentTreeSha,
598
- targetTreeSha,
599
- "--",
600
- ]);
601
-
602
- return result.stdout.split("\0").filter(Boolean);
603
- }
604
-
605
- async function restoreCommitExactly(targetCommitSha: string): Promise<{ changed: boolean; undoCommitSha?: string; targetTreeSha: string }> {
606
- const { treeSha: currentTreeSha } = await captureWorktreeTree();
607
- const targetTreeSha = await getCommitTreeSha(targetCommitSha);
608
-
609
- if (currentTreeSha === targetTreeSha) {
610
- lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
611
- return { changed: false, targetTreeSha };
612
- }
613
-
614
- const undoCommitSha = await ensureSnapshotForTree(currentTreeSha);
615
- const pathsToDelete = await getDeletedPaths(currentTreeSha, targetTreeSha);
616
- await deletePathsFromWorkingTree(pathsToDelete);
617
- await execGitChecked(["restore", `--source=${targetCommitSha}`, "--worktree", "--", "."]);
618
- lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
619
- return { changed: true, undoCommitSha, targetTreeSha };
620
- }
621
-
622
- function bindPendingPromptUser(entries: SessionLikeEntry[], collector: ActivePromptCollector) {
623
- if (!collector.pendingUserCommitSha) return;
624
-
625
- const userEntry = findLatestMatchingUserMessageEntry(entries, collector.promptText) ?? findLatestUserMessageEntry(entries);
626
- if (!userEntry) return;
627
- if (collector.bindings.some(([entryId]) => entryId === userEntry.id)) {
628
- collector.pendingUserCommitSha = undefined;
629
- return;
630
- }
631
-
632
- addBindingToCollector(collector, userEntry.id, collector.pendingUserCommitSha);
633
- collector.pendingUserCommitSha = undefined;
634
- }
635
-
636
- function appendRewindTurn(ctx: ExtensionContext, collector: ActivePromptCollector) {
637
- if (collector.bindings.length === 0) return;
638
-
639
- const data: RewindTurnData = {
640
- v: RETENTION_VERSION,
641
- snapshots: collector.snapshots,
642
- bindings: collector.bindings,
643
- };
644
-
645
- pi.appendEntry("rewind-turn", data);
646
- applyBindings(entryToCommit, data.snapshots, data.bindings);
647
-
648
- const latestBinding = data.bindings[data.bindings.length - 1];
649
- if (latestBinding) {
650
- activeBranchState.currentCommitSha = data.snapshots[latestBinding[1]];
651
- activeBranchState.currentTreeSha = lastExact?.commitSha === activeBranchState.currentCommitSha ? lastExact.treeSha : undefined;
652
- }
653
-
654
- updateStatus(ctx);
655
- }
656
-
657
- function appendRewindOp(ctx: ExtensionContext, data: RewindOpData) {
658
- const hasBindings = Boolean(data.bindings?.length);
659
- const hasCurrent = typeof data.current === "number";
660
- const hasUndo = typeof data.undo === "number";
661
- if (!hasBindings && !hasCurrent && !hasUndo) return;
662
-
663
- pi.appendEntry("rewind-op", data);
664
- applyBindings(entryToCommit, data.snapshots, data.bindings);
665
-
666
- const currentCommitSha = getCommitFromData(data, "current");
667
- if (currentCommitSha) {
668
- activeBranchState.currentCommitSha = currentCommitSha;
669
- activeBranchState.currentTreeSha = lastExact?.commitSha === currentCommitSha ? lastExact.treeSha : undefined;
670
- }
671
-
672
- const undoCommitSha = getCommitFromData(data, "undo");
673
- if (undoCommitSha) {
674
- activeBranchState.undoCommitSha = undoCommitSha;
675
- }
676
-
677
- updateStatus(ctx);
678
- }
679
-
680
- function appendForkPendingState(data: PendingResultingState) {
681
- const forkPending: RewindForkPendingData = {
682
- v: RETENTION_VERSION,
683
- current: data.currentCommitSha,
684
- };
685
-
686
- if (data.undoCommitSha) {
687
- forkPending.undo = data.undoCommitSha;
688
- }
689
-
690
- pi.appendEntry("rewind-fork-pending", forkPending);
691
- }
692
-
693
- function buildCurrentSessionLedger(ctx: ExtensionContext): ParsedSessionLedger {
694
- const ledger: ParsedSessionLedger = {
695
- sessionFile: currentSessionFile ?? "",
696
- sessionId: ctx.sessionManager.getSessionId(),
697
- cwd: ctx.sessionManager.getCwd(),
698
- parentSession: ctx.sessionManager.getHeader()?.parentSession,
699
- entryToCommit: new Map<string, string>(),
700
- labeledEntryIds: new Set<string>(),
701
- references: [],
702
- };
703
-
704
- for (const rawEntry of ctx.sessionManager.getEntries() as SessionLikeEntry[]) {
705
- if (rawEntry.type === "custom" && rawEntry.customType === "rewind-turn" && isRewindTurnData(rawEntry.data)) {
706
- applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings);
707
- addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data);
708
- continue;
709
- }
710
-
711
- if (rawEntry.type === "custom" && rawEntry.customType === "rewind-op" && isRewindOpData(rawEntry.data)) {
712
- applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings);
713
- addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data);
714
- const currentCommitSha = getCommitFromData(rawEntry.data, "current");
715
- if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha;
716
- const undoCommitSha = getCommitFromData(rawEntry.data, "undo");
717
- if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha;
718
- continue;
719
- }
720
-
721
- if (rawEntry.type === "custom" && rawEntry.customType === "rewind-fork-pending" && isRewindForkPendingData(rawEntry.data)) {
722
- ledger.latestForkPending = rawEntry.data;
723
- continue;
724
- }
725
-
726
- if (rawEntry.type === "label") {
727
- updateLabelSet(ledger.labeledEntryIds, rawEntry);
728
- }
729
- }
730
-
731
- return ledger;
732
- }
733
-
734
- async function parseSessionLedgerFile(sessionFile: string): Promise<ParsedSessionLedger | null> {
735
- try {
736
- const fileStat = await stat(sessionFile);
737
- const cached = parsedSessionCache.get(sessionFile);
738
- if (cached && cached.mtimeMs === fileStat.mtimeMs) {
739
- return cached.ledger;
740
- }
741
-
742
- const content = await readFile(sessionFile, "utf-8");
743
- const ledger: ParsedSessionLedger = {
744
- sessionFile,
745
- entryToCommit: new Map<string, string>(),
746
- labeledEntryIds: new Set<string>(),
747
- references: [],
748
- };
749
-
750
- const hasRewindEntries = content.includes('"rewind-');
751
-
752
- if (!hasRewindEntries) {
753
- // Fast path: extract session header only, skip line-by-line JSON parsing
754
- let pos = 0;
755
- for (let i = 0; i < 5 && pos < content.length; i++) {
756
- const nextNewline = content.indexOf("\n", pos);
757
- const line = nextNewline >= 0 ? content.substring(pos, nextNewline) : content.substring(pos);
758
- pos = nextNewline >= 0 ? nextNewline + 1 : content.length;
759
- if (!line) continue;
760
- try {
761
- const entry = JSON.parse(line);
762
- if (entry?.type === "session") {
763
- ledger.sessionId = entry.id;
764
- ledger.cwd = entry.cwd;
765
- ledger.parentSession = entry.parentSession;
766
- break;
767
- }
768
- } catch {
769
- // Ignore malformed header lines from partial/corrupt session files.
770
- continue;
771
- }
772
- }
773
- parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger });
774
- return ledger;
775
- }
776
-
777
- const lines = content.split("\n").filter(Boolean);
778
-
779
- for (const line of lines) {
780
- let entry: SessionLikeGenericEntry | null = null;
781
- try {
782
- const parsed = JSON.parse(line);
783
- if (parsed && typeof parsed === "object") {
784
- entry = parsed as SessionLikeGenericEntry;
785
- }
786
- } catch {
787
- // Ignore malformed JSONL lines; retention discovery is best-effort.
788
- continue;
789
- }
790
-
791
- if (!entry) continue;
792
-
793
- if (entry?.type === "session") {
794
- ledger.sessionId = entry.id;
795
- ledger.cwd = entry.cwd;
796
- ledger.parentSession = entry.parentSession;
797
- continue;
798
- }
799
-
800
- if (entry?.type === "custom" && entry?.customType === "rewind-turn" && isRewindTurnData(entry.data)) {
801
- applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings);
802
- addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data);
803
- continue;
804
- }
805
-
806
- if (entry?.type === "custom" && entry?.customType === "rewind-op" && isRewindOpData(entry.data)) {
807
- applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings);
808
- addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data);
809
- const currentCommitSha = getCommitFromData(entry.data, "current");
810
- if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha;
811
- const undoCommitSha = getCommitFromData(entry.data, "undo");
812
- if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha;
813
- continue;
814
- }
815
-
816
- if (entry?.type === "custom" && entry?.customType === "rewind-fork-pending" && isRewindForkPendingData(entry.data)) {
817
- ledger.latestForkPending = entry.data;
818
- continue;
819
- }
820
-
821
- if (entry?.type === "label") {
822
- updateLabelSet(ledger.labeledEntryIds, entry);
823
- }
824
- }
825
-
826
- parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger });
827
- return ledger;
828
- } catch {
829
- // Unreadable/missing session file: skip it and continue lineage/discovery.
830
- return null;
831
- }
832
- }
833
-
834
- async function resolveEntrySnapshotWithLineage(entryId: string, sessionFile = currentSessionFile): Promise<string | undefined> {
835
- let cursor = sessionFile;
836
-
837
- while (cursor) {
838
- const ledger = cursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(cursor);
839
- if (!ledger) break;
840
-
841
- const commitSha = ledger.entryToCommit.get(entryId);
842
- if (commitSha && (await commitExists(commitSha))) {
843
- return commitSha;
844
- }
845
-
846
- cursor = ledger.parentSession;
847
- }
848
-
849
- return undefined;
850
- }
851
-
852
- function buildCurrentSessionLedgerFromMemory(): ParsedSessionLedger {
853
- return {
854
- sessionFile: currentSessionFile ?? "",
855
- sessionId: sessionId ?? undefined,
856
- cwd: currentSessionCwd,
857
- parentSession: currentParentSession,
858
- entryToCommit: new Map(entryToCommit),
859
- labeledEntryIds: new Set(),
860
- references: [],
861
- latestCurrentCommitSha: activeBranchState.currentCommitSha,
862
- latestUndoCommitSha: activeBranchState.undoCommitSha,
863
- };
864
- }
865
-
866
- async function reconstructState(ctx: ExtensionContext) {
867
- entryToCommit.clear();
868
- activeBranchState = {};
869
- lastExact = null;
870
-
871
- const currentLedger = buildCurrentSessionLedger(ctx);
872
- for (const [entryId, commitSha] of currentLedger.entryToCommit.entries()) {
873
- entryToCommit.set(entryId, commitSha);
874
- }
875
-
876
- let latestVisibleBindingCommitSha: string | undefined;
877
- for (const entry of ctx.sessionManager.getBranch() as SessionLikeEntry[]) {
878
- const boundCommitSha = entry.id ? entryToCommit.get(entry.id) : undefined;
879
- if (boundCommitSha && isRestorableTreeEntry(entry)) {
880
- latestVisibleBindingCommitSha = boundCommitSha;
881
- }
882
-
883
- if (entry.type === "custom" && entry.customType === "rewind-op" && isRewindOpData(entry.data)) {
884
- const currentCommitSha = getCommitFromData(entry.data, "current");
885
- if (currentCommitSha) {
886
- activeBranchState.currentCommitSha = currentCommitSha;
887
- }
888
- const undoCommitSha = getCommitFromData(entry.data, "undo");
889
- if (undoCommitSha) {
890
- activeBranchState.undoCommitSha = undoCommitSha;
891
- }
892
- }
893
- }
894
-
895
- if (!activeBranchState.currentCommitSha) {
896
- activeBranchState.currentCommitSha = latestVisibleBindingCommitSha;
897
- }
898
-
899
- if (activeBranchState.currentCommitSha && (await commitExists(activeBranchState.currentCommitSha))) {
900
- activeBranchState.currentTreeSha = await getCommitTreeSha(activeBranchState.currentCommitSha);
901
- const { treeSha: worktreeTreeSha } = await captureWorktreeTree();
902
-
903
- if (activeBranchState.currentTreeSha === worktreeTreeSha) {
904
- lastExact = {
905
- commitSha: activeBranchState.currentCommitSha,
906
- treeSha: activeBranchState.currentTreeSha,
907
- };
908
- }
909
- }
910
- }
911
-
912
- async function discoverSessionFiles(scanMode: "ancestor-only" | "repo-sessions"): Promise<string[]> {
913
- const discovered = new Set<string>();
914
-
915
- if (scanMode === "repo-sessions") {
916
- const roots = new Set<string>();
917
- const defaultSessionsDir = getDefaultSessionsDir();
918
- if (existsSync(defaultSessionsDir)) {
919
- roots.add(defaultSessionsDir);
920
- }
921
- if (currentSessionFile) {
922
- roots.add(dirname(currentSessionFile));
923
- }
924
-
925
- const stack = [...roots];
926
- while (stack.length > 0) {
927
- const dir = stack.pop();
928
- if (!dir) continue;
929
-
930
- let entries: Awaited<ReturnType<typeof readdir>>;
931
- try {
932
- entries = await readdir(dir, { withFileTypes: true });
933
- } catch {
934
- // Skip unreadable directories during best-effort discovery.
935
- continue;
936
- }
937
-
938
- for (const entry of entries) {
939
- const fullPath = join(dir, entry.name);
940
- if (entry.isDirectory()) {
941
- stack.push(fullPath);
942
- continue;
943
- }
944
- if (entry.isFile() && entry.name.endsWith(".jsonl")) {
945
- discovered.add(fullPath);
946
- }
947
- }
948
- }
949
- }
950
-
951
- let ancestorCursor = currentSessionFile;
952
- while (ancestorCursor) {
953
- discovered.add(ancestorCursor);
954
- const ledger = ancestorCursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(ancestorCursor);
955
- ancestorCursor = ledger?.parentSession;
956
- }
957
-
958
- return [...discovered];
959
- }
960
-
961
- async function maybeSweepRetention(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
962
- const retention = getRetentionSettings();
963
- if (!retention) return;
964
- if (reason === "new-snapshots" && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
965
- if (reason === "shutdown" && sweepCompletedThisSession && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
966
- if (!repoRoot) return;
967
- if (sweepRunning) return;
968
- sweepRunning = true;
969
- try {
970
- await runRetentionSweep(ctx, reason);
971
- } finally {
972
- sweepRunning = false;
973
- }
974
- }
975
-
976
- async function runRetentionSweep(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
977
- const retention = getRetentionSettings();
978
- if (!retention) return;
979
-
980
- const scanMode = getRetentionScanMode();
981
- const startupBudgetMs = reason === "startup" ? getStartupSweepBudgetMs() : undefined;
982
- const startedAt = Date.now();
983
-
984
- const budgetExceeded = () => typeof startupBudgetMs === "number" && Date.now() - startedAt > startupBudgetMs;
985
-
986
- const sessionFiles = await discoverSessionFiles(scanMode);
987
- if (budgetExceeded()) {
988
- notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
989
- return;
990
- }
991
-
992
- const ledgers: ParsedSessionLedger[] = [];
993
-
994
- for (const sessionFile of sessionFiles) {
995
- if (budgetExceeded()) {
996
- notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
997
- return;
998
- }
999
-
1000
- const ledger = sessionFile === currentSessionFile ? buildCurrentSessionLedger(ctx) : await parseSessionLedgerFile(sessionFile);
1001
- if (!ledger?.cwd) continue;
1002
- if (!isInsidePath(ledger.cwd, repoRoot)) continue;
1003
- ledgers.push(ledger);
1004
- }
1005
-
1006
- const latestReferenceByCommit = new Map<string, number>();
1007
- const pinnedCommits = new Set<string>();
1008
- const currentCommits = new Set<string>();
1009
- const undoCommits = new Set<string>();
1010
-
1011
- for (const ledger of ledgers) {
1012
- if (budgetExceeded()) {
1013
- notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
1014
- return;
1015
- }
1016
-
1017
- for (const reference of ledger.references) {
1018
- const prev = latestReferenceByCommit.get(reference.commitSha) ?? 0;
1019
- if (reference.timestamp > prev) {
1020
- latestReferenceByCommit.set(reference.commitSha, reference.timestamp);
1021
- }
1022
- if (reference.kind === "binding" && retention.pinLabeledEntries && reference.entryId && ledger.labeledEntryIds.has(reference.entryId)) {
1023
- pinnedCommits.add(reference.commitSha);
1024
- }
1025
- }
1026
-
1027
- if (ledger.latestCurrentCommitSha) {
1028
- currentCommits.add(ledger.latestCurrentCommitSha);
1029
- }
1030
- if (ledger.latestUndoCommitSha) {
1031
- undoCommits.add(ledger.latestUndoCommitSha);
1032
- }
1033
- }
1034
-
1035
- for (const commitSha of [...currentCommits, ...undoCommits]) {
1036
- if (budgetExceeded()) {
1037
- notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
1038
- return;
1039
- }
1040
- if (await commitExists(commitSha)) {
1041
- pinnedCommits.add(commitSha);
1042
- }
1043
- }
1044
-
1045
- let candidates = [...latestReferenceByCommit.entries()]
1046
- .filter(([commitSha]) => !pinnedCommits.has(commitSha))
1047
- .sort((left, right) => right[1] - left[1]);
1048
-
1049
- if (typeof retention.maxAgeDays === "number" && retention.maxAgeDays >= 0) {
1050
- const cutoff = Date.now() - retention.maxAgeDays * 24 * 60 * 60 * 1000;
1051
- candidates = candidates.filter(([, timestamp]) => timestamp >= cutoff);
1052
- }
1053
-
1054
- if (typeof retention.maxSnapshots === "number" && retention.maxSnapshots >= 0 && candidates.length > retention.maxSnapshots) {
1055
- candidates = candidates.slice(0, retention.maxSnapshots);
1056
- }
1057
-
1058
- const liveSet = [...new Set([...pinnedCommits, ...candidates.map(([commitSha]) => commitSha)])];
1059
- const existingLiveSet: string[] = [];
1060
- for (const commitSha of liveSet) {
1061
- if (budgetExceeded()) {
1062
- notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
1063
- return;
1064
- }
1065
- if (await commitExists(commitSha)) {
1066
- existingLiveSet.push(commitSha);
1067
- }
1068
- }
1069
-
1070
- const rewriteResult = await rewriteStoreToLiveSet(existingLiveSet);
1071
- if (rewriteResult === "preserved-empty") {
1072
- return;
1073
- }
1074
-
1075
- // Skip gc on background startup sweeps to avoid racing with concurrent snapshot creation
1076
- if (reason !== "startup") {
1077
- try {
1078
- const result = await pi.exec("git", ["gc", "--auto"]);
1079
- if (result.code !== 0) {
1080
- throw new Error(result.stderr.trim() || `git gc --auto failed with code ${result.code}`);
1081
- }
1082
- } catch {
1083
- // Best effort only; retention reachability rewrite already completed.
1084
- }
1085
- }
1086
-
1087
- newSnapshotsSinceSweep = 0;
1088
- sweepCompletedThisSession = true;
1089
- updateStatus(ctx);
1090
- }
1091
-
1092
- let activeContext: ExtensionContext | undefined;
1093
-
1094
- async function checkpointEntry(ctx: ExtensionContext, entryId: string) {
1095
- syncSessionIdentity(ctx);
1096
- if (!isGitRepo) return;
1097
- const entry = ctx.sessionManager.getEntry(entryId) as SessionLikeEntry | undefined;
1098
- if (!isRestorableTreeEntry(entry)) return;
1099
- if (entryToCommit.has(entryId)) return;
1100
-
1101
- const currentCommitSha = await ensureSnapshotForCurrentWorktree();
1102
- appendRewindOp(ctx, {
1103
- v: RETENTION_VERSION,
1104
- snapshots: [currentCommitSha],
1105
- bindings: [[entryId, 0]],
1106
- });
1107
- await reconstructState(ctx);
1108
- updateStatus(ctx);
1109
- }
1110
-
1111
- async function initializeForSession(ctx: ExtensionContext) {
1112
- activeContext = ctx;
1113
- resetState();
1114
- syncSessionIdentity(ctx);
1115
-
1116
- try {
1117
- const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
1118
- isGitRepo = result.code === 0 && result.stdout.trim() === "true";
1119
- } catch {
1120
- // Treat git probing failures as non-git context for this session.
1121
- isGitRepo = false;
1122
- }
1123
-
1124
- if (!isGitRepo) {
1125
- updateStatus(ctx);
1126
- return;
1127
- }
1128
-
1129
- await getRepoRoot(pi.exec);
1130
- await reconstructState(ctx);
1131
- updateStatus(ctx);
1132
- maybeSweepRetention(ctx, "startup").catch((error) => {
1133
- notify(ctx, `Rewind retention startup sweep failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
1134
- });
1135
- }
1136
-
1137
- pi.events.on("rewind:fork-preference", (data) => {
1138
- if (!data || typeof data !== "object") return;
1139
- if (!("mode" in data) || data.mode !== "conversation-only") return;
1140
- if (!("source" in data) || typeof data.source !== "string") return;
1141
- if (!FORK_PREFERENCE_SOURCE_ALLOWLIST.has(data.source)) return;
1142
- forceConversationOnlyOnNextFork = true;
1143
- forceConversationOnlySource = data.source;
1144
- });
1145
-
1146
- pi.events.on("rewind:checkpoint-entry", (data) => {
1147
- if (!data || typeof data !== "object") return;
1148
- if (!("source" in data) || typeof data.source !== "string") return;
1149
- if (!CHECKPOINT_SOURCE_ALLOWLIST.has(data.source)) return;
1150
- if (!("entryId" in data) || typeof data.entryId !== "string") return;
1151
- const ctx = activeContext;
1152
- if (!ctx) return;
1153
-
1154
- checkpointEntry(ctx, data.entryId).catch((error) => {
1155
- notify(ctx, `Rewind: failed to checkpoint ${data.entryId} (${error instanceof Error ? error.message : String(error)})`, "warning");
1156
- });
1157
- });
1158
-
1159
- pi.on("before_agent_start", async (event) => {
1160
- activePromptText = event.prompt;
1161
- });
1162
-
1163
- pi.on("session_start", async (event, ctx) => {
1164
- await initializeForSession(ctx);
1165
-
1166
- if (event.reason !== "fork" || !event.previousSessionFile || !isGitRepo) {
1167
- return;
1168
- }
1169
-
1170
- const previousLedger = await parseSessionLedgerFile(event.previousSessionFile);
1171
- const pendingFork = previousLedger?.latestForkPending;
1172
- if (!pendingFork) {
1173
- return;
1174
- }
1175
-
1176
- if (!(await commitExists(pendingFork.current))) {
1177
- return;
1178
- }
1179
-
1180
- const snapshots = [pendingFork.current];
1181
- const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
1182
-
1183
- if (pendingFork.undo && (await commitExists(pendingFork.undo))) {
1184
- data.snapshots.push(pendingFork.undo);
1185
- data.undo = 1;
1186
- }
1187
-
1188
- appendRewindOp(ctx, data);
1189
- await reconstructState(ctx);
1190
- updateStatus(ctx);
1191
- });
1192
-
1193
- pi.on("session_tree", async (event, ctx) => {
1194
- syncSessionIdentity(ctx);
1195
- if (!isGitRepo || !pendingTreeState) {
1196
- await reconstructState(ctx);
1197
- updateStatus(ctx);
1198
- return;
1199
- }
1200
-
1201
- const snapshots = [pendingTreeState.currentCommitSha];
1202
- const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
1203
- if (pendingTreeState.undoCommitSha) {
1204
- data.snapshots.push(pendingTreeState.undoCommitSha);
1205
- data.undo = 1;
1206
- }
1207
- if (event.summaryEntry?.id) {
1208
- data.bindings = [[event.summaryEntry.id, 0]];
1209
- }
1210
-
1211
- appendRewindOp(ctx, data);
1212
- pendingTreeState = null;
1213
- await reconstructState(ctx);
1214
- updateStatus(ctx);
1215
- });
1216
-
1217
- pi.on("session_compact", async (event, ctx) => {
1218
- syncSessionIdentity(ctx);
1219
- if (!isGitRepo) return;
1220
-
1221
- let currentCommitSha = activeBranchState.currentCommitSha;
1222
- if (!currentCommitSha) {
1223
- currentCommitSha = await ensureSnapshotForCurrentWorktree();
1224
- }
1225
-
1226
- appendRewindOp(ctx, {
1227
- v: RETENTION_VERSION,
1228
- snapshots: [currentCommitSha],
1229
- bindings: [[event.compactionEntry.id, 0]],
1230
- });
1231
- await reconstructState(ctx);
1232
- updateStatus(ctx);
1233
- });
1234
-
1235
- pi.on("session_shutdown", async (_event, ctx) => {
1236
- syncSessionIdentity(ctx);
1237
- if (!isGitRepo) return;
1238
- await maybeSweepRetention(ctx, "shutdown");
1239
- });
1240
-
1241
- pi.on("turn_start", async (event, ctx) => {
1242
- if (!isGitRepo) return;
1243
- if (event.turnIndex !== 0) return;
1244
-
1245
- try {
1246
- const { treeSha } = await captureWorktreeTree();
1247
- const commitSha = await ensureSnapshotForTree(treeSha);
1248
- promptCollector = {
1249
- snapshots: [],
1250
- bindings: [],
1251
- promptText: activePromptText ?? undefined,
1252
- pendingUserCommitSha: commitSha,
1253
- };
1254
-
1255
- bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector);
1256
- } catch (error) {
1257
- promptCollector = null;
1258
- notify(ctx, `Rewind: failed to capture start snapshot (${error instanceof Error ? error.message : String(error)})`, "warning");
1259
- }
1260
- });
1261
-
1262
- pi.on("turn_end", async (event, ctx) => {
1263
- if (!isGitRepo || !promptCollector) return;
1264
-
1265
- try {
1266
- const branchEntries = ctx.sessionManager.getBranch() as SessionLikeEntry[];
1267
- bindPendingPromptUser(branchEntries, promptCollector);
1268
-
1269
- if (event.message.role !== "assistant") return;
1270
-
1271
- const assistantEntry = findAssistantEntryForTurn(branchEntries, event.message);
1272
- if (!assistantEntry) return;
1273
-
1274
- const { treeSha } = await captureWorktreeTree();
1275
- const commitSha = await ensureSnapshotForTree(treeSha);
1276
- addBindingToCollector(promptCollector, assistantEntry.id, commitSha);
1277
- } catch (error) {
1278
- notify(ctx, `Rewind: failed to capture assistant snapshot (${error instanceof Error ? error.message : String(error)})`, "warning");
1279
- }
1280
- });
1281
-
1282
- pi.on("agent_end", async (_event, ctx) => {
1283
- if (!isGitRepo || !promptCollector) return;
1284
-
1285
- try {
1286
- bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector);
1287
- appendRewindTurn(ctx, promptCollector);
1288
- await reconstructState(ctx);
1289
- updateStatus(ctx);
1290
- await maybeSweepRetention(ctx, "new-snapshots");
1291
- } catch (error) {
1292
- notify(ctx, `Rewind: failed to finalize rewind turn (${error instanceof Error ? error.message : String(error)})`, "warning");
1293
- } finally {
1294
- promptCollector = null;
1295
- activePromptText = null;
1296
- }
1297
- });
1298
-
1299
- pi.on("session_before_fork", async (event, ctx) => {
1300
- const shouldForceConversationOnly = forceConversationOnlyOnNextFork;
1301
- const forcedBySource = forceConversationOnlySource;
1302
- forceConversationOnlyOnNextFork = false;
1303
- forceConversationOnlySource = null;
1304
-
1305
- if (!isGitRepo) return;
1306
-
1307
- try {
1308
- if (!ctx.hasUI) {
1309
- const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1310
- appendForkPendingState(nextState);
1311
- return;
1312
- }
1313
-
1314
- const targetCommitSha = await resolveEntrySnapshotWithLineage(event.entryId);
1315
- const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha)));
1316
-
1317
- if (shouldForceConversationOnly) {
1318
- const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1319
- appendForkPendingState(nextState);
1320
- notify(ctx, `Rewind: using conversation-only fork (keep current files)${forcedBySource ? ` (${forcedBySource})` : ""}`);
1321
- return;
1322
- }
1323
-
1324
- const options = ["Conversation only (keep current files)"];
1325
- if (targetCommitSha) {
1326
- options.push("Restore all (files + conversation)", "Code only (restore files, keep conversation)");
1327
- }
1328
- if (hasUndo) {
1329
- options.push("Undo last file rewind");
1330
- }
1331
-
1332
- const choice = await ctx.ui.select("Restore Options", options);
1333
- if (!choice) {
1334
- notify(ctx, "Rewind cancelled");
1335
- return { cancel: true };
1336
- }
1337
-
1338
- if (choice === "Undo last file rewind") {
1339
- const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!);
1340
- const nextState = {
1341
- currentCommitSha: activeBranchState.undoCommitSha!,
1342
- undoCommitSha: restore.undoCommitSha,
1343
- };
1344
- appendForkPendingState(nextState);
1345
- notify(ctx, "Files restored to before last rewind");
1346
- return;
1347
- }
1348
-
1349
- if (choice === "Conversation only (keep current files)") {
1350
- const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1351
- appendForkPendingState(nextState);
1352
- return;
1353
- }
1354
-
1355
- if (!targetCommitSha) {
1356
- notify(ctx, "No exact rewind point available for that entry", "error");
1357
- return { cancel: true };
1358
- }
1359
-
1360
- const restore = await restoreCommitExactly(targetCommitSha);
1361
- const nextState = {
1362
- currentCommitSha: targetCommitSha,
1363
- undoCommitSha: restore.undoCommitSha,
1364
- };
1365
- appendForkPendingState(nextState);
1366
- notify(ctx, "Files restored from rewind point");
1367
-
1368
- if (choice === "Code only (restore files, keep conversation)") {
1369
- return { skipConversationRestore: true };
1370
- }
1371
- } catch (error) {
1372
- notify(ctx, `Rewind failed before fork: ${error instanceof Error ? error.message : String(error)}`, "error");
1373
- return { cancel: true };
1374
- }
1375
- });
1376
-
1377
- pi.on("session_before_tree", async (event, ctx) => {
1378
- if (!isGitRepo) return;
1379
-
1380
- try {
1381
- if ((globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress === true) {
1382
- pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1383
- return;
1384
- }
1385
-
1386
- if (!ctx.hasUI) return;
1387
-
1388
- const targetEntry = ctx.sessionManager.getEntry(event.preparation.targetId) as SessionLikeEntry | undefined;
1389
- const targetCommitSha = isRestorableTreeEntry(targetEntry)
1390
- ? await resolveEntrySnapshotWithLineage(event.preparation.targetId, currentSessionFile)
1391
- : undefined;
1392
- const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha)));
1393
-
1394
- const options = ["Keep current files"];
1395
- if (targetCommitSha) {
1396
- options.push("Restore files to that point");
1397
- }
1398
- if (hasUndo) {
1399
- options.push("Undo last file rewind");
1400
- }
1401
- options.push("Cancel navigation");
1402
-
1403
- const choice = await ctx.ui.select("Restore Options", options);
1404
- if (!choice || choice === "Cancel navigation") {
1405
- notify(ctx, "Navigation cancelled");
1406
- return { cancel: true };
1407
- }
1408
-
1409
- if (choice === "Undo last file rewind") {
1410
- const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!);
1411
- const snapshots = [activeBranchState.undoCommitSha!];
1412
- const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
1413
- if (restore.undoCommitSha) {
1414
- data.snapshots.push(restore.undoCommitSha);
1415
- data.undo = 1;
1416
- }
1417
- appendRewindOp(ctx, data);
1418
- notify(ctx, "Files restored to before last rewind");
1419
- await reconstructState(ctx);
1420
- return { cancel: true };
1421
- }
1422
-
1423
- if (choice === "Keep current files") {
1424
- pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1425
- return;
1426
- }
1427
-
1428
- if (!targetCommitSha) {
1429
- notify(ctx, "Exact file rewind is only available for user, assistant, custom message, compaction, and summary nodes", "error");
1430
- return { cancel: true };
1431
- }
1432
-
1433
- const restore = await restoreCommitExactly(targetCommitSha);
1434
- pendingTreeState = {
1435
- currentCommitSha: targetCommitSha,
1436
- undoCommitSha: restore.undoCommitSha,
1437
- };
1438
- notify(ctx, "Files restored to rewind point");
1439
- } catch (error) {
1440
- pendingTreeState = null;
1441
- notify(ctx, `Rewind failed before tree navigation: ${error instanceof Error ? error.message : String(error)}`, "error");
1442
- return { cancel: true };
1443
- }
1444
- });
1445
- }
1
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@selesai/code";
2
+ import { exec as execCb } from "child_process";
3
+ import { existsSync, readFileSync, realpathSync } from "fs";
4
+ import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
5
+ import { tmpdir } from "os";
6
+ import { dirname, isAbsolute, join, relative, resolve } from "path";
7
+ import { promisify } from "util";
8
+
9
+ const execAsync = promisify(execCb);
10
+
11
+ const STORE_REF = "refs/pi-rewind/store";
12
+ const STATUS_KEY = "rewind";
13
+ const FORK_PREFERENCE_SOURCE_ALLOWLIST = new Set(["fork-from-first"]);
14
+ const CHECKPOINT_SOURCE_ALLOWLIST = new Set(["pi-custom-compaction"]);
15
+ const LEGACY_ZERO_SHA = "0000000000000000000000000000000000000000";
16
+ const RETENTION_SWEEP_THRESHOLD = 50;
17
+ const RETENTION_VERSION = 2;
18
+ const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
19
+
20
+ type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
21
+
22
+ type GitExecResult = Awaited<ReturnType<ExecFn>>;
23
+
24
+ type BindingTuple = [entryId: string, snapshotIndex: number];
25
+
26
+ interface RewindSettings {
27
+ rewind?: {
28
+ silentCheckpoints?: boolean;
29
+ retention?: {
30
+ maxSnapshots?: number;
31
+ maxAgeDays?: number;
32
+ pinLabeledEntries?: boolean;
33
+ scanMode?: "ancestor-only" | "repo-sessions";
34
+ startupBudgetMs?: number;
35
+ };
36
+ };
37
+ }
38
+
39
+ type RewindRetentionSettings = NonNullable<NonNullable<RewindSettings["rewind"]>["retention"]>;
40
+
41
+ interface RewindTurnData {
42
+ v: 2;
43
+ snapshots: string[];
44
+ bindings: BindingTuple[];
45
+ }
46
+
47
+ interface RewindOpData {
48
+ v: 2;
49
+ snapshots: string[];
50
+ bindings?: BindingTuple[];
51
+ current?: number;
52
+ undo?: number;
53
+ }
54
+
55
+ interface RewindForkPendingData {
56
+ v: 2;
57
+ current: string;
58
+ undo?: string;
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
+ latestForkPending?: RewindForkPendingData;
102
+ }
103
+
104
+ interface SessionLikeMessageEntry {
105
+ type: "message";
106
+ id: string;
107
+ parentId: string | null;
108
+ timestamp: string;
109
+ message: {
110
+ role: string;
111
+ timestamp?: number;
112
+ content?: unknown;
113
+ };
114
+ }
115
+
116
+ interface SessionLikeCustomEntry {
117
+ type: "custom";
118
+ id: string;
119
+ parentId: string | null;
120
+ timestamp: string;
121
+ customType: string;
122
+ data?: unknown;
123
+ }
124
+
125
+ interface SessionLikeLabelEntry {
126
+ type: "label";
127
+ targetId: string;
128
+ label?: string;
129
+ }
130
+
131
+ interface SessionLikeBranchSummaryEntry {
132
+ type: "branch_summary";
133
+ id: string;
134
+ }
135
+
136
+ interface SessionLikeGenericEntry {
137
+ type: string;
138
+ id?: string;
139
+ parentId?: string | null;
140
+ timestamp?: string;
141
+ message?: {
142
+ role?: string;
143
+ timestamp?: number;
144
+ content?: unknown;
145
+ };
146
+ customType?: string;
147
+ data?: unknown;
148
+ targetId?: string;
149
+ label?: string;
150
+ }
151
+
152
+ type SessionLikeEntry =
153
+ | SessionLikeMessageEntry
154
+ | SessionLikeCustomEntry
155
+ | SessionLikeLabelEntry
156
+ | SessionLikeBranchSummaryEntry
157
+ | SessionLikeGenericEntry;
158
+
159
+ let cachedSettings: RewindSettings | null = null;
160
+
161
+ function getSettingsFilePath(): string {
162
+ return join(getAgentDir(), "settings.json");
163
+ }
164
+
165
+ function getDefaultSessionsDir(): string {
166
+ return join(getAgentDir(), "sessions");
167
+ }
168
+
169
+ function getSettings(): RewindSettings {
170
+ if (cachedSettings) {
171
+ return cachedSettings;
172
+ }
173
+
174
+ try {
175
+ cachedSettings = JSON.parse(readFileSync(getSettingsFilePath(), "utf-8")) as RewindSettings;
176
+ } catch {
177
+ // Invalid/missing settings must not disable rewind; fall back to defaults.
178
+ cachedSettings = {};
179
+ }
180
+
181
+ return cachedSettings;
182
+ }
183
+
184
+ function getSilentCheckpointsSetting(): boolean {
185
+ return getSettings().rewind?.silentCheckpoints === true;
186
+ }
187
+
188
+ function getRetentionSettings(): RewindRetentionSettings | undefined {
189
+ return getSettings().rewind?.retention;
190
+ }
191
+
192
+ function getRetentionScanMode(): "ancestor-only" | "repo-sessions" {
193
+ return getRetentionSettings()?.scanMode === "repo-sessions" ? "repo-sessions" : "ancestor-only";
194
+ }
195
+
196
+ function getStartupSweepBudgetMs(): number | undefined {
197
+ const value = getRetentionSettings()?.startupBudgetMs;
198
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
199
+ return undefined;
200
+ }
201
+ return value;
202
+ }
203
+
204
+ function isRewindTurnData(value: unknown): value is RewindTurnData {
205
+ if (!value || typeof value !== "object") return false;
206
+ const data = value as Partial<RewindTurnData>;
207
+ return data.v === 2 && Array.isArray(data.snapshots) && Array.isArray(data.bindings);
208
+ }
209
+
210
+ function isRewindOpData(value: unknown): value is RewindOpData {
211
+ if (!value || typeof value !== "object") return false;
212
+ const data = value as Partial<RewindOpData>;
213
+ return data.v === 2 && Array.isArray(data.snapshots);
214
+ }
215
+
216
+ function isRewindForkPendingData(value: unknown): value is RewindForkPendingData {
217
+ if (!value || typeof value !== "object") return false;
218
+ const data = value as Partial<RewindForkPendingData>;
219
+ return data.v === 2 && typeof data.current === "string" && data.current.length > 0;
220
+ }
221
+
222
+ function canonicalizePath(value: string): string {
223
+ const resolvedValue = resolve(value);
224
+ try {
225
+ return realpathSync.native(resolvedValue);
226
+ } catch {
227
+ // Path may not exist yet; compare with the resolved path directly.
228
+ return resolvedValue;
229
+ }
230
+ }
231
+
232
+ function isInsidePath(targetPath: string, parentPath: string): boolean {
233
+ const resolvedTarget = canonicalizePath(targetPath);
234
+ const resolvedParent = canonicalizePath(parentPath);
235
+ const rel = relative(resolvedParent, resolvedTarget);
236
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
237
+ }
238
+
239
+ function toTimestamp(value: string | undefined): number {
240
+ if (!value) return 0;
241
+ const parsed = Date.parse(value);
242
+ return Number.isFinite(parsed) ? parsed : 0;
243
+ }
244
+
245
+ function getTextContent(content: unknown): string {
246
+ if (typeof content === "string") return content;
247
+ if (!Array.isArray(content)) return "";
248
+ return content
249
+ .filter((block): block is { type: string; text?: string } => !!block && typeof block === "object")
250
+ .filter((block) => block.type === "text")
251
+ .map((block) => block.text ?? "")
252
+ .join("\n");
253
+ }
254
+
255
+ function updateLabelSet(labelIds: Set<string>, entry: SessionLikeLabelEntry) {
256
+ if (!entry.targetId) return;
257
+ if (entry.label && entry.label.trim()) {
258
+ labelIds.add(entry.targetId);
259
+ return;
260
+ }
261
+ labelIds.delete(entry.targetId);
262
+ }
263
+
264
+ function applyBindings(target: Map<string, string>, snapshots: string[], bindings?: BindingTuple[]) {
265
+ if (!bindings) return;
266
+ for (const [entryId, snapshotIndex] of bindings) {
267
+ const commitSha = snapshots[snapshotIndex];
268
+ if (entryId && commitSha) {
269
+ target.set(entryId, commitSha);
270
+ }
271
+ }
272
+ }
273
+
274
+ function addReferences(target: ParsedLedgerReference[], snapshots: string[], timestamp: number, data: RewindTurnData | RewindOpData) {
275
+ if ("bindings" in data && data.bindings) {
276
+ for (const [entryId, snapshotIndex] of data.bindings) {
277
+ const commitSha = snapshots[snapshotIndex];
278
+ if (!commitSha) continue;
279
+ target.push({ commitSha, entryId, timestamp, kind: "binding" });
280
+ }
281
+ }
282
+
283
+ if ("current" in data && typeof data.current === "number") {
284
+ const commitSha = snapshots[data.current];
285
+ if (commitSha) {
286
+ target.push({ commitSha, timestamp, kind: "current" });
287
+ }
288
+ }
289
+
290
+ if ("undo" in data && typeof data.undo === "number") {
291
+ const commitSha = snapshots[data.undo];
292
+ if (commitSha) {
293
+ target.push({ commitSha, timestamp, kind: "undo" });
294
+ }
295
+ }
296
+ }
297
+
298
+ function resolveBindingSnapshotIndex(snapshots: string[], commitSha: string): number {
299
+ const existingIndex = snapshots.indexOf(commitSha);
300
+ if (existingIndex >= 0) return existingIndex;
301
+ snapshots.push(commitSha);
302
+ return snapshots.length - 1;
303
+ }
304
+
305
+ function addBindingToCollector(collector: ActivePromptCollector, entryId: string, commitSha: string) {
306
+ const snapshotIndex = resolveBindingSnapshotIndex(collector.snapshots, commitSha);
307
+ collector.bindings.push([entryId, snapshotIndex]);
308
+ }
309
+
310
+ function getCommitFromData(data: RewindOpData, indexKey: "current" | "undo"): string | undefined {
311
+ const snapshotIndex = data[indexKey];
312
+ return typeof snapshotIndex === "number" ? data.snapshots[snapshotIndex] : undefined;
313
+ }
314
+
315
+ function isRestorableTreeEntry(entry: SessionLikeEntry | undefined): boolean {
316
+ if (!entry) return false;
317
+ if (entry.type === "message") {
318
+ return entry.message.role === "user" || entry.message.role === "assistant";
319
+ }
320
+ return entry.type === "branch_summary" || entry.type === "compaction" || entry.type === "custom_message";
321
+ }
322
+
323
+ function isAssistantMessageEntry(entry: SessionLikeEntry): entry is SessionLikeMessageEntry {
324
+ return entry.type === "message" && entry.message.role === "assistant";
325
+ }
326
+
327
+ function findLatestUserMessageEntry(entries: SessionLikeEntry[]): SessionLikeMessageEntry | null {
328
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
329
+ const entry = entries[index];
330
+ if (entry.type === "message" && entry.message.role === "user") {
331
+ return entry;
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+
337
+ function findLatestMatchingUserMessageEntry(
338
+ entries: SessionLikeEntry[],
339
+ promptText: string | null | undefined,
340
+ ): SessionLikeMessageEntry | null {
341
+ if (!promptText) return null;
342
+
343
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
344
+ const entry = entries[index];
345
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
346
+ if (getTextContent(entry.message.content) === promptText) {
347
+ return entry;
348
+ }
349
+ }
350
+
351
+ return null;
352
+ }
353
+
354
+ function findAssistantEntryForTurn(entries: SessionLikeEntry[], message: { timestamp?: number; content?: unknown }): SessionLikeMessageEntry | null {
355
+ const targetTimestamp = message.timestamp;
356
+ const targetText = getTextContent(message.content);
357
+
358
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
359
+ const entry = entries[index];
360
+ if (!isAssistantMessageEntry(entry)) continue;
361
+
362
+ if (targetTimestamp !== undefined && entry.message.timestamp === targetTimestamp) {
363
+ return entry;
364
+ }
365
+
366
+ if (targetText && getTextContent(entry.message.content) === targetText) {
367
+ return entry;
368
+ }
369
+ }
370
+
371
+ return null;
372
+ }
373
+
374
+ export default function rewindExtension(pi: ExtensionAPI) {
375
+ const entryToCommit = new Map<string, string>();
376
+ const parsedSessionCache = new Map<string, { mtimeMs: number; ledger: ParsedSessionLedger }>();
377
+
378
+ let repoRoot: string | null = null;
379
+ let sessionId: string | null = null;
380
+ let currentSessionFile: string | undefined;
381
+ let currentParentSession: string | undefined;
382
+ let currentSessionCwd: string | undefined;
383
+ let isGitRepo = false;
384
+ let lastExact: ExactState | null = null;
385
+ let activeBranchState: ActiveBranchState = {};
386
+ let promptCollector: ActivePromptCollector | null = null;
387
+ let pendingTreeState: PendingResultingState | null = null;
388
+ let activePromptText: string | null = null;
389
+ let newSnapshotsSinceSweep = 0;
390
+ let sweepRunning = false;
391
+ let sweepCompletedThisSession = false;
392
+ let forceConversationOnlyOnNextFork = false;
393
+ let forceConversationOnlySource: string | null = null;
394
+
395
+ function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info") {
396
+ if (!ctx.hasUI) return;
397
+ if (level === "info" && getSilentCheckpointsSetting()) return;
398
+ ctx.ui.notify(message, level);
399
+ }
400
+
401
+ function updateStatus(ctx: ExtensionContext) {
402
+ if (!ctx.hasUI) return;
403
+ if (!isGitRepo || getSilentCheckpointsSetting()) {
404
+ ctx.ui.setStatus(STATUS_KEY, undefined);
405
+ return;
406
+ }
407
+
408
+ const uniqueSnapshots = new Set(entryToCommit.values()).size;
409
+ const theme = ctx.ui.theme;
410
+ ctx.ui.setStatus(
411
+ STATUS_KEY,
412
+ theme.fg("dim", "◆ ") + theme.fg("muted", `${entryToCommit.size} points / ${uniqueSnapshots} snapshots`),
413
+ );
414
+ }
415
+
416
+ function resetState() {
417
+ entryToCommit.clear();
418
+ parsedSessionCache.clear();
419
+ repoRoot = null;
420
+ sessionId = null;
421
+ currentSessionFile = undefined;
422
+ currentParentSession = undefined;
423
+ currentSessionCwd = undefined;
424
+ isGitRepo = false;
425
+ lastExact = null;
426
+ activeBranchState = {};
427
+ promptCollector = null;
428
+ pendingTreeState = null;
429
+ activePromptText = null;
430
+ newSnapshotsSinceSweep = 0;
431
+ sweepCompletedThisSession = false;
432
+ forceConversationOnlyOnNextFork = false;
433
+ forceConversationOnlySource = null;
434
+ cachedSettings = null;
435
+ }
436
+
437
+ function syncSessionIdentity(ctx: ExtensionContext) {
438
+ sessionId = ctx.sessionManager.getSessionId();
439
+ currentSessionFile = ctx.sessionManager.getSessionFile();
440
+ currentParentSession = ctx.sessionManager.getHeader()?.parentSession;
441
+ currentSessionCwd = ctx.sessionManager.getCwd();
442
+ }
443
+
444
+ async function execGitChecked(args: string[]): Promise<GitExecResult> {
445
+ const result = await pi.exec("git", args);
446
+ if (result.code !== 0) {
447
+ const stderr = result.stderr.trim();
448
+ throw new Error(stderr || `git ${args.join(" ")} failed with code ${result.code}`);
449
+ }
450
+ return result;
451
+ }
452
+
453
+ async function getRepoRoot(exec: ExecFn): Promise<string> {
454
+ if (repoRoot) return repoRoot;
455
+ const result = await exec("git", ["rev-parse", "--show-toplevel"]);
456
+ if (result.code !== 0) {
457
+ const stderr = result.stderr.trim();
458
+ throw new Error(stderr || `git rev-parse --show-toplevel failed with code ${result.code}`);
459
+ }
460
+ repoRoot = result.stdout.trim();
461
+ return repoRoot;
462
+ }
463
+
464
+ async function captureWorktreeTree(): Promise<{ treeSha: string }> {
465
+ const root = await getRepoRoot(pi.exec);
466
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-rewind-"));
467
+ const tempIndex = join(tempDir, "index");
468
+
469
+ try {
470
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
471
+ await execAsync("git add -A", { cwd: root, env });
472
+ const { stdout } = await execAsync("git write-tree", { cwd: root, env });
473
+ return { treeSha: stdout.trim() };
474
+ } finally {
475
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {
476
+ // Best effort cleanup for temporary index directory.
477
+ });
478
+ }
479
+ }
480
+
481
+ async function getCommitTreeSha(commitSha: string): Promise<string> {
482
+ const result = await execGitChecked(["show", "-s", "--format=%T", commitSha]);
483
+ return result.stdout.trim();
484
+ }
485
+
486
+ async function commitExists(commitSha: string): Promise<boolean> {
487
+ const result = await pi.exec("git", ["cat-file", "-e", `${commitSha}^{commit}`]);
488
+ return result.code === 0;
489
+ }
490
+
491
+ async function getStoreHead(): Promise<string | undefined> {
492
+ const result = await pi.exec("git", ["rev-parse", "--verify", STORE_REF]);
493
+ if (result.code !== 0) {
494
+ return undefined;
495
+ }
496
+ const value = result.stdout.trim();
497
+ return value || undefined;
498
+ }
499
+
500
+ async function createStoreKeepaliveCommit(snapshotCommitSha: string, previousStoreHead?: string): Promise<string> {
501
+ const args = ["commit-tree", EMPTY_TREE_SHA];
502
+
503
+ if (previousStoreHead) {
504
+ args.push("-p", previousStoreHead);
505
+ }
506
+
507
+ args.push("-p", snapshotCommitSha, "-m", "pi rewind store");
508
+ const result = await execGitChecked(args);
509
+ return result.stdout.trim();
510
+ }
511
+
512
+ async function appendSnapshotToStore(commitSha: string): Promise<void> {
513
+ let attempts = 0;
514
+ let lastError: unknown;
515
+
516
+ while (attempts < 5) {
517
+ attempts += 1;
518
+ const oldHead = await getStoreHead();
519
+ const keepaliveCommit = await createStoreKeepaliveCommit(commitSha, oldHead);
520
+
521
+ try {
522
+ if (oldHead) {
523
+ await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, oldHead]);
524
+ } else {
525
+ await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, LEGACY_ZERO_SHA]);
526
+ }
527
+ return;
528
+ } catch (error) {
529
+ // Retry if another process updated the store ref concurrently.
530
+ // Keep the most recent error for actionable failure context.
531
+ lastError = error;
532
+ }
533
+ }
534
+
535
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
536
+ throw new Error(`failed to update rewind store ref: ${detail}`);
537
+ }
538
+
539
+ async function rewriteStoreToLiveSet(liveCommitShas: string[]): Promise<"rewritten" | "preserved-empty"> {
540
+ const uniqueLiveCommits = [...new Set(liveCommitShas.filter(Boolean))];
541
+ if (uniqueLiveCommits.length === 0) {
542
+ return "preserved-empty";
543
+ }
544
+
545
+ let head: string | undefined;
546
+ for (const commitSha of uniqueLiveCommits) {
547
+ head = await createStoreKeepaliveCommit(commitSha, head);
548
+ }
549
+
550
+ const oldHead = await getStoreHead();
551
+ if (oldHead) {
552
+ await execGitChecked(["update-ref", STORE_REF, head!, oldHead]);
553
+ return "rewritten";
554
+ }
555
+
556
+ await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]);
557
+ return "rewritten";
558
+ }
559
+
560
+ async function ensureSnapshotForTree(treeSha: string): Promise<string> {
561
+ if (lastExact && lastExact.treeSha === treeSha) {
562
+ return lastExact.commitSha;
563
+ }
564
+
565
+ const result = await execGitChecked(["commit-tree", treeSha, "-m", "pi rewind snapshot"]);
566
+ const commitSha = result.stdout.trim();
567
+ await appendSnapshotToStore(commitSha);
568
+ lastExact = { commitSha, treeSha };
569
+ newSnapshotsSinceSweep += 1;
570
+ return commitSha;
571
+ }
572
+
573
+ async function ensureSnapshotForCurrentWorktree(): Promise<string> {
574
+ const { treeSha } = await captureWorktreeTree();
575
+ return ensureSnapshotForTree(treeSha);
576
+ }
577
+
578
+ async function deletePathsFromWorkingTree(paths: string[]) {
579
+ if (paths.length === 0) return;
580
+ const root = await getRepoRoot(pi.exec);
581
+
582
+ for (const repoRelativePath of paths) {
583
+ const absolutePath = resolve(root, repoRelativePath);
584
+ if (!isInsidePath(absolutePath, root)) {
585
+ throw new Error(`refusing to delete path outside repo root: ${repoRelativePath}`);
586
+ }
587
+ await rm(absolutePath, { recursive: true, force: true });
588
+ }
589
+ }
590
+
591
+ async function getDeletedPaths(currentTreeSha: string, targetTreeSha: string): Promise<string[]> {
592
+ const result = await execGitChecked([
593
+ "diff",
594
+ "--name-only",
595
+ "--diff-filter=D",
596
+ "-z",
597
+ currentTreeSha,
598
+ targetTreeSha,
599
+ "--",
600
+ ]);
601
+
602
+ return result.stdout.split("\0").filter(Boolean);
603
+ }
604
+
605
+ async function restoreCommitExactly(targetCommitSha: string): Promise<{ changed: boolean; undoCommitSha?: string; targetTreeSha: string }> {
606
+ const { treeSha: currentTreeSha } = await captureWorktreeTree();
607
+ const targetTreeSha = await getCommitTreeSha(targetCommitSha);
608
+
609
+ if (currentTreeSha === targetTreeSha) {
610
+ lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
611
+ return { changed: false, targetTreeSha };
612
+ }
613
+
614
+ const undoCommitSha = await ensureSnapshotForTree(currentTreeSha);
615
+ const pathsToDelete = await getDeletedPaths(currentTreeSha, targetTreeSha);
616
+ await deletePathsFromWorkingTree(pathsToDelete);
617
+ await execGitChecked(["restore", `--source=${targetCommitSha}`, "--worktree", "--", "."]);
618
+ lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha };
619
+ return { changed: true, undoCommitSha, targetTreeSha };
620
+ }
621
+
622
+ function bindPendingPromptUser(entries: SessionLikeEntry[], collector: ActivePromptCollector) {
623
+ if (!collector.pendingUserCommitSha) return;
624
+
625
+ const userEntry = findLatestMatchingUserMessageEntry(entries, collector.promptText) ?? findLatestUserMessageEntry(entries);
626
+ if (!userEntry) return;
627
+ if (collector.bindings.some(([entryId]) => entryId === userEntry.id)) {
628
+ collector.pendingUserCommitSha = undefined;
629
+ return;
630
+ }
631
+
632
+ addBindingToCollector(collector, userEntry.id, collector.pendingUserCommitSha);
633
+ collector.pendingUserCommitSha = undefined;
634
+ }
635
+
636
+ function appendRewindTurn(ctx: ExtensionContext, collector: ActivePromptCollector) {
637
+ if (collector.bindings.length === 0) return;
638
+
639
+ const data: RewindTurnData = {
640
+ v: RETENTION_VERSION,
641
+ snapshots: collector.snapshots,
642
+ bindings: collector.bindings,
643
+ };
644
+
645
+ pi.appendEntry("rewind-turn", data);
646
+ applyBindings(entryToCommit, data.snapshots, data.bindings);
647
+
648
+ const latestBinding = data.bindings[data.bindings.length - 1];
649
+ if (latestBinding) {
650
+ activeBranchState.currentCommitSha = data.snapshots[latestBinding[1]];
651
+ activeBranchState.currentTreeSha = lastExact?.commitSha === activeBranchState.currentCommitSha ? lastExact.treeSha : undefined;
652
+ }
653
+
654
+ updateStatus(ctx);
655
+ }
656
+
657
+ function appendRewindOp(ctx: ExtensionContext, data: RewindOpData) {
658
+ const hasBindings = Boolean(data.bindings?.length);
659
+ const hasCurrent = typeof data.current === "number";
660
+ const hasUndo = typeof data.undo === "number";
661
+ if (!hasBindings && !hasCurrent && !hasUndo) return;
662
+
663
+ pi.appendEntry("rewind-op", data);
664
+ applyBindings(entryToCommit, data.snapshots, data.bindings);
665
+
666
+ const currentCommitSha = getCommitFromData(data, "current");
667
+ if (currentCommitSha) {
668
+ activeBranchState.currentCommitSha = currentCommitSha;
669
+ activeBranchState.currentTreeSha = lastExact?.commitSha === currentCommitSha ? lastExact.treeSha : undefined;
670
+ }
671
+
672
+ const undoCommitSha = getCommitFromData(data, "undo");
673
+ if (undoCommitSha) {
674
+ activeBranchState.undoCommitSha = undoCommitSha;
675
+ }
676
+
677
+ updateStatus(ctx);
678
+ }
679
+
680
+ function appendForkPendingState(data: PendingResultingState) {
681
+ const forkPending: RewindForkPendingData = {
682
+ v: RETENTION_VERSION,
683
+ current: data.currentCommitSha,
684
+ };
685
+
686
+ if (data.undoCommitSha) {
687
+ forkPending.undo = data.undoCommitSha;
688
+ }
689
+
690
+ pi.appendEntry("rewind-fork-pending", forkPending);
691
+ }
692
+
693
+ function buildCurrentSessionLedger(ctx: ExtensionContext): ParsedSessionLedger {
694
+ const ledger: ParsedSessionLedger = {
695
+ sessionFile: currentSessionFile ?? "",
696
+ sessionId: ctx.sessionManager.getSessionId(),
697
+ cwd: ctx.sessionManager.getCwd(),
698
+ parentSession: ctx.sessionManager.getHeader()?.parentSession,
699
+ entryToCommit: new Map<string, string>(),
700
+ labeledEntryIds: new Set<string>(),
701
+ references: [],
702
+ };
703
+
704
+ for (const rawEntry of ctx.sessionManager.getEntries() as SessionLikeEntry[]) {
705
+ if (rawEntry.type === "custom" && rawEntry.customType === "rewind-turn" && isRewindTurnData(rawEntry.data)) {
706
+ applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings);
707
+ addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data);
708
+ continue;
709
+ }
710
+
711
+ if (rawEntry.type === "custom" && rawEntry.customType === "rewind-op" && isRewindOpData(rawEntry.data)) {
712
+ applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings);
713
+ addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data);
714
+ const currentCommitSha = getCommitFromData(rawEntry.data, "current");
715
+ if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha;
716
+ const undoCommitSha = getCommitFromData(rawEntry.data, "undo");
717
+ if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha;
718
+ continue;
719
+ }
720
+
721
+ if (rawEntry.type === "custom" && rawEntry.customType === "rewind-fork-pending" && isRewindForkPendingData(rawEntry.data)) {
722
+ ledger.latestForkPending = rawEntry.data;
723
+ continue;
724
+ }
725
+
726
+ if (rawEntry.type === "label") {
727
+ updateLabelSet(ledger.labeledEntryIds, rawEntry);
728
+ }
729
+ }
730
+
731
+ return ledger;
732
+ }
733
+
734
+ async function parseSessionLedgerFile(sessionFile: string): Promise<ParsedSessionLedger | null> {
735
+ try {
736
+ const fileStat = await stat(sessionFile);
737
+ const cached = parsedSessionCache.get(sessionFile);
738
+ if (cached && cached.mtimeMs === fileStat.mtimeMs) {
739
+ return cached.ledger;
740
+ }
741
+
742
+ const content = await readFile(sessionFile, "utf-8");
743
+ const ledger: ParsedSessionLedger = {
744
+ sessionFile,
745
+ entryToCommit: new Map<string, string>(),
746
+ labeledEntryIds: new Set<string>(),
747
+ references: [],
748
+ };
749
+
750
+ const hasRewindEntries = content.includes('"rewind-');
751
+
752
+ if (!hasRewindEntries) {
753
+ // Fast path: extract session header only, skip line-by-line JSON parsing
754
+ let pos = 0;
755
+ for (let i = 0; i < 5 && pos < content.length; i++) {
756
+ const nextNewline = content.indexOf("\n", pos);
757
+ const line = nextNewline >= 0 ? content.substring(pos, nextNewline) : content.substring(pos);
758
+ pos = nextNewline >= 0 ? nextNewline + 1 : content.length;
759
+ if (!line) continue;
760
+ try {
761
+ const entry = JSON.parse(line);
762
+ if (entry?.type === "session") {
763
+ ledger.sessionId = entry.id;
764
+ ledger.cwd = entry.cwd;
765
+ ledger.parentSession = entry.parentSession;
766
+ break;
767
+ }
768
+ } catch {
769
+ // Ignore malformed header lines from partial/corrupt session files.
770
+ continue;
771
+ }
772
+ }
773
+ parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger });
774
+ return ledger;
775
+ }
776
+
777
+ const lines = content.split("\n").filter(Boolean);
778
+
779
+ for (const line of lines) {
780
+ let entry: SessionLikeGenericEntry | null = null;
781
+ try {
782
+ const parsed = JSON.parse(line);
783
+ if (parsed && typeof parsed === "object") {
784
+ entry = parsed as SessionLikeGenericEntry;
785
+ }
786
+ } catch {
787
+ // Ignore malformed JSONL lines; retention discovery is best-effort.
788
+ continue;
789
+ }
790
+
791
+ if (!entry) continue;
792
+
793
+ if (entry?.type === "session") {
794
+ ledger.sessionId = entry.id;
795
+ ledger.cwd = entry.cwd;
796
+ ledger.parentSession = entry.parentSession;
797
+ continue;
798
+ }
799
+
800
+ if (entry?.type === "custom" && entry?.customType === "rewind-turn" && isRewindTurnData(entry.data)) {
801
+ applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings);
802
+ addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data);
803
+ continue;
804
+ }
805
+
806
+ if (entry?.type === "custom" && entry?.customType === "rewind-op" && isRewindOpData(entry.data)) {
807
+ applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings);
808
+ addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data);
809
+ const currentCommitSha = getCommitFromData(entry.data, "current");
810
+ if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha;
811
+ const undoCommitSha = getCommitFromData(entry.data, "undo");
812
+ if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha;
813
+ continue;
814
+ }
815
+
816
+ if (entry?.type === "custom" && entry?.customType === "rewind-fork-pending" && isRewindForkPendingData(entry.data)) {
817
+ ledger.latestForkPending = entry.data;
818
+ continue;
819
+ }
820
+
821
+ if (entry?.type === "label") {
822
+ updateLabelSet(ledger.labeledEntryIds, entry);
823
+ }
824
+ }
825
+
826
+ parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger });
827
+ return ledger;
828
+ } catch {
829
+ // Unreadable/missing session file: skip it and continue lineage/discovery.
830
+ return null;
831
+ }
832
+ }
833
+
834
+ async function resolveEntrySnapshotWithLineage(entryId: string, sessionFile = currentSessionFile): Promise<string | undefined> {
835
+ let cursor = sessionFile;
836
+
837
+ while (cursor) {
838
+ const ledger = cursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(cursor);
839
+ if (!ledger) break;
840
+
841
+ const commitSha = ledger.entryToCommit.get(entryId);
842
+ if (commitSha && (await commitExists(commitSha))) {
843
+ return commitSha;
844
+ }
845
+
846
+ cursor = ledger.parentSession;
847
+ }
848
+
849
+ return undefined;
850
+ }
851
+
852
+ function buildCurrentSessionLedgerFromMemory(): ParsedSessionLedger {
853
+ return {
854
+ sessionFile: currentSessionFile ?? "",
855
+ sessionId: sessionId ?? undefined,
856
+ cwd: currentSessionCwd,
857
+ parentSession: currentParentSession,
858
+ entryToCommit: new Map(entryToCommit),
859
+ labeledEntryIds: new Set(),
860
+ references: [],
861
+ latestCurrentCommitSha: activeBranchState.currentCommitSha,
862
+ latestUndoCommitSha: activeBranchState.undoCommitSha,
863
+ };
864
+ }
865
+
866
+ async function reconstructState(ctx: ExtensionContext) {
867
+ entryToCommit.clear();
868
+ activeBranchState = {};
869
+ lastExact = null;
870
+
871
+ const currentLedger = buildCurrentSessionLedger(ctx);
872
+ for (const [entryId, commitSha] of currentLedger.entryToCommit.entries()) {
873
+ entryToCommit.set(entryId, commitSha);
874
+ }
875
+
876
+ let latestVisibleBindingCommitSha: string | undefined;
877
+ for (const entry of ctx.sessionManager.getBranch() as SessionLikeEntry[]) {
878
+ const boundCommitSha = entry.id ? entryToCommit.get(entry.id) : undefined;
879
+ if (boundCommitSha && isRestorableTreeEntry(entry)) {
880
+ latestVisibleBindingCommitSha = boundCommitSha;
881
+ }
882
+
883
+ if (entry.type === "custom" && entry.customType === "rewind-op" && isRewindOpData(entry.data)) {
884
+ const currentCommitSha = getCommitFromData(entry.data, "current");
885
+ if (currentCommitSha) {
886
+ activeBranchState.currentCommitSha = currentCommitSha;
887
+ }
888
+ const undoCommitSha = getCommitFromData(entry.data, "undo");
889
+ if (undoCommitSha) {
890
+ activeBranchState.undoCommitSha = undoCommitSha;
891
+ }
892
+ }
893
+ }
894
+
895
+ if (!activeBranchState.currentCommitSha) {
896
+ activeBranchState.currentCommitSha = latestVisibleBindingCommitSha;
897
+ }
898
+
899
+ if (activeBranchState.currentCommitSha && (await commitExists(activeBranchState.currentCommitSha))) {
900
+ activeBranchState.currentTreeSha = await getCommitTreeSha(activeBranchState.currentCommitSha);
901
+ const { treeSha: worktreeTreeSha } = await captureWorktreeTree();
902
+
903
+ if (activeBranchState.currentTreeSha === worktreeTreeSha) {
904
+ lastExact = {
905
+ commitSha: activeBranchState.currentCommitSha,
906
+ treeSha: activeBranchState.currentTreeSha,
907
+ };
908
+ }
909
+ }
910
+ }
911
+
912
+ async function discoverSessionFiles(scanMode: "ancestor-only" | "repo-sessions"): Promise<string[]> {
913
+ const discovered = new Set<string>();
914
+
915
+ if (scanMode === "repo-sessions") {
916
+ const roots = new Set<string>();
917
+ const defaultSessionsDir = getDefaultSessionsDir();
918
+ if (existsSync(defaultSessionsDir)) {
919
+ roots.add(defaultSessionsDir);
920
+ }
921
+ if (currentSessionFile) {
922
+ roots.add(dirname(currentSessionFile));
923
+ }
924
+
925
+ const stack = [...roots];
926
+ while (stack.length > 0) {
927
+ const dir = stack.pop();
928
+ if (!dir) continue;
929
+
930
+ let entries: Awaited<ReturnType<typeof readdir>>;
931
+ try {
932
+ entries = await readdir(dir, { withFileTypes: true });
933
+ } catch {
934
+ // Skip unreadable directories during best-effort discovery.
935
+ continue;
936
+ }
937
+
938
+ for (const entry of entries) {
939
+ const fullPath = join(dir, entry.name);
940
+ if (entry.isDirectory()) {
941
+ stack.push(fullPath);
942
+ continue;
943
+ }
944
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
945
+ discovered.add(fullPath);
946
+ }
947
+ }
948
+ }
949
+ }
950
+
951
+ let ancestorCursor = currentSessionFile;
952
+ while (ancestorCursor) {
953
+ discovered.add(ancestorCursor);
954
+ const ledger = ancestorCursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(ancestorCursor);
955
+ ancestorCursor = ledger?.parentSession;
956
+ }
957
+
958
+ return [...discovered];
959
+ }
960
+
961
+ async function maybeSweepRetention(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
962
+ const retention = getRetentionSettings();
963
+ if (!retention) return;
964
+ if (reason === "new-snapshots" && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
965
+ if (reason === "shutdown" && sweepCompletedThisSession && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return;
966
+ if (!repoRoot) return;
967
+ if (sweepRunning) return;
968
+ sweepRunning = true;
969
+ try {
970
+ await runRetentionSweep(ctx, reason);
971
+ } finally {
972
+ sweepRunning = false;
973
+ }
974
+ }
975
+
976
+ async function runRetentionSweep(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") {
977
+ const retention = getRetentionSettings();
978
+ if (!retention) return;
979
+
980
+ const scanMode = getRetentionScanMode();
981
+ const startupBudgetMs = reason === "startup" ? getStartupSweepBudgetMs() : undefined;
982
+ const startedAt = Date.now();
983
+
984
+ const budgetExceeded = () => typeof startupBudgetMs === "number" && Date.now() - startedAt > startupBudgetMs;
985
+
986
+ const sessionFiles = await discoverSessionFiles(scanMode);
987
+ if (budgetExceeded()) {
988
+ notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
989
+ return;
990
+ }
991
+
992
+ const ledgers: ParsedSessionLedger[] = [];
993
+
994
+ for (const sessionFile of sessionFiles) {
995
+ if (budgetExceeded()) {
996
+ notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
997
+ return;
998
+ }
999
+
1000
+ const ledger = sessionFile === currentSessionFile ? buildCurrentSessionLedger(ctx) : await parseSessionLedgerFile(sessionFile);
1001
+ if (!ledger?.cwd) continue;
1002
+ if (!isInsidePath(ledger.cwd, repoRoot)) continue;
1003
+ ledgers.push(ledger);
1004
+ }
1005
+
1006
+ const latestReferenceByCommit = new Map<string, number>();
1007
+ const pinnedCommits = new Set<string>();
1008
+ const currentCommits = new Set<string>();
1009
+ const undoCommits = new Set<string>();
1010
+
1011
+ for (const ledger of ledgers) {
1012
+ if (budgetExceeded()) {
1013
+ notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
1014
+ return;
1015
+ }
1016
+
1017
+ for (const reference of ledger.references) {
1018
+ const prev = latestReferenceByCommit.get(reference.commitSha) ?? 0;
1019
+ if (reference.timestamp > prev) {
1020
+ latestReferenceByCommit.set(reference.commitSha, reference.timestamp);
1021
+ }
1022
+ if (reference.kind === "binding" && retention.pinLabeledEntries && reference.entryId && ledger.labeledEntryIds.has(reference.entryId)) {
1023
+ pinnedCommits.add(reference.commitSha);
1024
+ }
1025
+ }
1026
+
1027
+ if (ledger.latestCurrentCommitSha) {
1028
+ currentCommits.add(ledger.latestCurrentCommitSha);
1029
+ }
1030
+ if (ledger.latestUndoCommitSha) {
1031
+ undoCommits.add(ledger.latestUndoCommitSha);
1032
+ }
1033
+ }
1034
+
1035
+ for (const commitSha of [...currentCommits, ...undoCommits]) {
1036
+ if (budgetExceeded()) {
1037
+ notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
1038
+ return;
1039
+ }
1040
+ if (await commitExists(commitSha)) {
1041
+ pinnedCommits.add(commitSha);
1042
+ }
1043
+ }
1044
+
1045
+ let candidates = [...latestReferenceByCommit.entries()]
1046
+ .filter(([commitSha]) => !pinnedCommits.has(commitSha))
1047
+ .sort((left, right) => right[1] - left[1]);
1048
+
1049
+ if (typeof retention.maxAgeDays === "number" && retention.maxAgeDays >= 0) {
1050
+ const cutoff = Date.now() - retention.maxAgeDays * 24 * 60 * 60 * 1000;
1051
+ candidates = candidates.filter(([, timestamp]) => timestamp >= cutoff);
1052
+ }
1053
+
1054
+ if (typeof retention.maxSnapshots === "number" && retention.maxSnapshots >= 0 && candidates.length > retention.maxSnapshots) {
1055
+ candidates = candidates.slice(0, retention.maxSnapshots);
1056
+ }
1057
+
1058
+ const liveSet = [...new Set([...pinnedCommits, ...candidates.map(([commitSha]) => commitSha)])];
1059
+ const existingLiveSet: string[] = [];
1060
+ for (const commitSha of liveSet) {
1061
+ if (budgetExceeded()) {
1062
+ notify(ctx, `Rewind retention startup sweep skipped after exceeding ${startupBudgetMs}ms budget`, "warning");
1063
+ return;
1064
+ }
1065
+ if (await commitExists(commitSha)) {
1066
+ existingLiveSet.push(commitSha);
1067
+ }
1068
+ }
1069
+
1070
+ const rewriteResult = await rewriteStoreToLiveSet(existingLiveSet);
1071
+ if (rewriteResult === "preserved-empty") {
1072
+ return;
1073
+ }
1074
+
1075
+ // Skip gc on background startup sweeps to avoid racing with concurrent snapshot creation
1076
+ if (reason !== "startup") {
1077
+ try {
1078
+ const result = await pi.exec("git", ["gc", "--auto"]);
1079
+ if (result.code !== 0) {
1080
+ throw new Error(result.stderr.trim() || `git gc --auto failed with code ${result.code}`);
1081
+ }
1082
+ } catch {
1083
+ // Best effort only; retention reachability rewrite already completed.
1084
+ }
1085
+ }
1086
+
1087
+ newSnapshotsSinceSweep = 0;
1088
+ sweepCompletedThisSession = true;
1089
+ updateStatus(ctx);
1090
+ }
1091
+
1092
+ let activeContext: ExtensionContext | undefined;
1093
+
1094
+ async function checkpointEntry(ctx: ExtensionContext, entryId: string) {
1095
+ syncSessionIdentity(ctx);
1096
+ if (!isGitRepo) return;
1097
+ const entry = ctx.sessionManager.getEntry(entryId) as SessionLikeEntry | undefined;
1098
+ if (!isRestorableTreeEntry(entry)) return;
1099
+ if (entryToCommit.has(entryId)) return;
1100
+
1101
+ const currentCommitSha = await ensureSnapshotForCurrentWorktree();
1102
+ appendRewindOp(ctx, {
1103
+ v: RETENTION_VERSION,
1104
+ snapshots: [currentCommitSha],
1105
+ bindings: [[entryId, 0]],
1106
+ });
1107
+ await reconstructState(ctx);
1108
+ updateStatus(ctx);
1109
+ }
1110
+
1111
+ async function initializeForSession(ctx: ExtensionContext) {
1112
+ activeContext = ctx;
1113
+ resetState();
1114
+ syncSessionIdentity(ctx);
1115
+
1116
+ try {
1117
+ const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]);
1118
+ isGitRepo = result.code === 0 && result.stdout.trim() === "true";
1119
+ } catch {
1120
+ // Treat git probing failures as non-git context for this session.
1121
+ isGitRepo = false;
1122
+ }
1123
+
1124
+ if (!isGitRepo) {
1125
+ updateStatus(ctx);
1126
+ return;
1127
+ }
1128
+
1129
+ await getRepoRoot(pi.exec);
1130
+ await reconstructState(ctx);
1131
+ updateStatus(ctx);
1132
+ maybeSweepRetention(ctx, "startup").catch((error) => {
1133
+ notify(ctx, `Rewind retention startup sweep failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
1134
+ });
1135
+ }
1136
+
1137
+ pi.events.on("rewind:fork-preference", (data) => {
1138
+ if (!data || typeof data !== "object") return;
1139
+ if (!("mode" in data) || data.mode !== "conversation-only") return;
1140
+ if (!("source" in data) || typeof data.source !== "string") return;
1141
+ if (!FORK_PREFERENCE_SOURCE_ALLOWLIST.has(data.source)) return;
1142
+ forceConversationOnlyOnNextFork = true;
1143
+ forceConversationOnlySource = data.source;
1144
+ });
1145
+
1146
+ pi.events.on("rewind:checkpoint-entry", (data) => {
1147
+ if (!data || typeof data !== "object") return;
1148
+ if (!("source" in data) || typeof data.source !== "string") return;
1149
+ if (!CHECKPOINT_SOURCE_ALLOWLIST.has(data.source)) return;
1150
+ if (!("entryId" in data) || typeof data.entryId !== "string") return;
1151
+ const ctx = activeContext;
1152
+ if (!ctx) return;
1153
+
1154
+ checkpointEntry(ctx, data.entryId).catch((error) => {
1155
+ notify(ctx, `Rewind: failed to checkpoint ${data.entryId} (${error instanceof Error ? error.message : String(error)})`, "warning");
1156
+ });
1157
+ });
1158
+
1159
+ pi.on("before_agent_start", async (event) => {
1160
+ activePromptText = event.prompt;
1161
+ });
1162
+
1163
+ pi.on("session_start", async (event, ctx) => {
1164
+ await initializeForSession(ctx);
1165
+
1166
+ if (event.reason !== "fork" || !event.previousSessionFile || !isGitRepo) {
1167
+ return;
1168
+ }
1169
+
1170
+ const previousLedger = await parseSessionLedgerFile(event.previousSessionFile);
1171
+ const pendingFork = previousLedger?.latestForkPending;
1172
+ if (!pendingFork) {
1173
+ return;
1174
+ }
1175
+
1176
+ if (!(await commitExists(pendingFork.current))) {
1177
+ return;
1178
+ }
1179
+
1180
+ const snapshots = [pendingFork.current];
1181
+ const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
1182
+
1183
+ if (pendingFork.undo && (await commitExists(pendingFork.undo))) {
1184
+ data.snapshots.push(pendingFork.undo);
1185
+ data.undo = 1;
1186
+ }
1187
+
1188
+ appendRewindOp(ctx, data);
1189
+ await reconstructState(ctx);
1190
+ updateStatus(ctx);
1191
+ });
1192
+
1193
+ pi.on("session_tree", async (event, ctx) => {
1194
+ syncSessionIdentity(ctx);
1195
+ if (!isGitRepo || !pendingTreeState) {
1196
+ await reconstructState(ctx);
1197
+ updateStatus(ctx);
1198
+ return;
1199
+ }
1200
+
1201
+ const snapshots = [pendingTreeState.currentCommitSha];
1202
+ const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
1203
+ if (pendingTreeState.undoCommitSha) {
1204
+ data.snapshots.push(pendingTreeState.undoCommitSha);
1205
+ data.undo = 1;
1206
+ }
1207
+ if (event.summaryEntry?.id) {
1208
+ data.bindings = [[event.summaryEntry.id, 0]];
1209
+ }
1210
+
1211
+ appendRewindOp(ctx, data);
1212
+ pendingTreeState = null;
1213
+ await reconstructState(ctx);
1214
+ updateStatus(ctx);
1215
+ });
1216
+
1217
+ pi.on("session_compact", async (event, ctx) => {
1218
+ syncSessionIdentity(ctx);
1219
+ if (!isGitRepo) return;
1220
+
1221
+ let currentCommitSha = activeBranchState.currentCommitSha;
1222
+ if (!currentCommitSha) {
1223
+ currentCommitSha = await ensureSnapshotForCurrentWorktree();
1224
+ }
1225
+
1226
+ appendRewindOp(ctx, {
1227
+ v: RETENTION_VERSION,
1228
+ snapshots: [currentCommitSha],
1229
+ bindings: [[event.compactionEntry.id, 0]],
1230
+ });
1231
+ await reconstructState(ctx);
1232
+ updateStatus(ctx);
1233
+ });
1234
+
1235
+ pi.on("session_shutdown", async (_event, ctx) => {
1236
+ syncSessionIdentity(ctx);
1237
+ if (!isGitRepo) return;
1238
+ await maybeSweepRetention(ctx, "shutdown");
1239
+ });
1240
+
1241
+ pi.on("turn_start", async (event, ctx) => {
1242
+ if (!isGitRepo) return;
1243
+ if (event.turnIndex !== 0) return;
1244
+
1245
+ try {
1246
+ const { treeSha } = await captureWorktreeTree();
1247
+ const commitSha = await ensureSnapshotForTree(treeSha);
1248
+ promptCollector = {
1249
+ snapshots: [],
1250
+ bindings: [],
1251
+ promptText: activePromptText ?? undefined,
1252
+ pendingUserCommitSha: commitSha,
1253
+ };
1254
+
1255
+ bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector);
1256
+ } catch (error) {
1257
+ promptCollector = null;
1258
+ notify(ctx, `Rewind: failed to capture start snapshot (${error instanceof Error ? error.message : String(error)})`, "warning");
1259
+ }
1260
+ });
1261
+
1262
+ pi.on("turn_end", async (event, ctx) => {
1263
+ if (!isGitRepo || !promptCollector) return;
1264
+
1265
+ try {
1266
+ const branchEntries = ctx.sessionManager.getBranch() as SessionLikeEntry[];
1267
+ bindPendingPromptUser(branchEntries, promptCollector);
1268
+
1269
+ if (event.message.role !== "assistant") return;
1270
+
1271
+ const assistantEntry = findAssistantEntryForTurn(branchEntries, event.message);
1272
+ if (!assistantEntry) return;
1273
+
1274
+ const { treeSha } = await captureWorktreeTree();
1275
+ const commitSha = await ensureSnapshotForTree(treeSha);
1276
+ addBindingToCollector(promptCollector, assistantEntry.id, commitSha);
1277
+ } catch (error) {
1278
+ notify(ctx, `Rewind: failed to capture assistant snapshot (${error instanceof Error ? error.message : String(error)})`, "warning");
1279
+ }
1280
+ });
1281
+
1282
+ pi.on("agent_end", async (_event, ctx) => {
1283
+ if (!isGitRepo || !promptCollector) return;
1284
+
1285
+ try {
1286
+ bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector);
1287
+ appendRewindTurn(ctx, promptCollector);
1288
+ await reconstructState(ctx);
1289
+ updateStatus(ctx);
1290
+ await maybeSweepRetention(ctx, "new-snapshots");
1291
+ } catch (error) {
1292
+ notify(ctx, `Rewind: failed to finalize rewind turn (${error instanceof Error ? error.message : String(error)})`, "warning");
1293
+ } finally {
1294
+ promptCollector = null;
1295
+ activePromptText = null;
1296
+ }
1297
+ });
1298
+
1299
+ pi.on("session_before_fork", async (event, ctx) => {
1300
+ const shouldForceConversationOnly = forceConversationOnlyOnNextFork;
1301
+ const forcedBySource = forceConversationOnlySource;
1302
+ forceConversationOnlyOnNextFork = false;
1303
+ forceConversationOnlySource = null;
1304
+
1305
+ if (!isGitRepo) return;
1306
+
1307
+ try {
1308
+ if (!ctx.hasUI) {
1309
+ const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1310
+ appendForkPendingState(nextState);
1311
+ return;
1312
+ }
1313
+
1314
+ const targetCommitSha = await resolveEntrySnapshotWithLineage(event.entryId);
1315
+ const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha)));
1316
+
1317
+ if (shouldForceConversationOnly) {
1318
+ const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1319
+ appendForkPendingState(nextState);
1320
+ notify(ctx, `Rewind: using conversation-only fork (keep current files)${forcedBySource ? ` (${forcedBySource})` : ""}`);
1321
+ return;
1322
+ }
1323
+
1324
+ const options = ["Conversation only (keep current files)"];
1325
+ if (targetCommitSha) {
1326
+ options.push("Restore all (files + conversation)", "Code only (restore files, keep conversation)");
1327
+ }
1328
+ if (hasUndo) {
1329
+ options.push("Undo last file rewind");
1330
+ }
1331
+
1332
+ const choice = await ctx.ui.select("Restore Options", options);
1333
+ if (!choice) {
1334
+ notify(ctx, "Rewind cancelled");
1335
+ return { cancel: true };
1336
+ }
1337
+
1338
+ if (choice === "Undo last file rewind") {
1339
+ const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!);
1340
+ const nextState = {
1341
+ currentCommitSha: activeBranchState.undoCommitSha!,
1342
+ undoCommitSha: restore.undoCommitSha,
1343
+ };
1344
+ appendForkPendingState(nextState);
1345
+ notify(ctx, "Files restored to before last rewind");
1346
+ return;
1347
+ }
1348
+
1349
+ if (choice === "Conversation only (keep current files)") {
1350
+ const nextState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1351
+ appendForkPendingState(nextState);
1352
+ return;
1353
+ }
1354
+
1355
+ if (!targetCommitSha) {
1356
+ notify(ctx, "No exact rewind point available for that entry", "error");
1357
+ return { cancel: true };
1358
+ }
1359
+
1360
+ const restore = await restoreCommitExactly(targetCommitSha);
1361
+ const nextState = {
1362
+ currentCommitSha: targetCommitSha,
1363
+ undoCommitSha: restore.undoCommitSha,
1364
+ };
1365
+ appendForkPendingState(nextState);
1366
+ notify(ctx, "Files restored from rewind point");
1367
+
1368
+ if (choice === "Code only (restore files, keep conversation)") {
1369
+ return { skipConversationRestore: true };
1370
+ }
1371
+ } catch (error) {
1372
+ notify(ctx, `Rewind failed before fork: ${error instanceof Error ? error.message : String(error)}`, "error");
1373
+ return { cancel: true };
1374
+ }
1375
+ });
1376
+
1377
+ pi.on("session_before_tree", async (event, ctx) => {
1378
+ if (!isGitRepo) return;
1379
+
1380
+ try {
1381
+ if ((globalThis as typeof globalThis & { __boomerangCollapseInProgress?: boolean }).__boomerangCollapseInProgress === true) {
1382
+ pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1383
+ return;
1384
+ }
1385
+
1386
+ if (!ctx.hasUI) return;
1387
+
1388
+ const targetEntry = ctx.sessionManager.getEntry(event.preparation.targetId) as SessionLikeEntry | undefined;
1389
+ const targetCommitSha = isRestorableTreeEntry(targetEntry)
1390
+ ? await resolveEntrySnapshotWithLineage(event.preparation.targetId, currentSessionFile)
1391
+ : undefined;
1392
+ const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha)));
1393
+
1394
+ const options = ["Keep current files"];
1395
+ if (targetCommitSha) {
1396
+ options.push("Restore files to that point");
1397
+ }
1398
+ if (hasUndo) {
1399
+ options.push("Undo last file rewind");
1400
+ }
1401
+ options.push("Cancel navigation");
1402
+
1403
+ const choice = await ctx.ui.select("Restore Options", options);
1404
+ if (!choice || choice === "Cancel navigation") {
1405
+ notify(ctx, "Navigation cancelled");
1406
+ return { cancel: true };
1407
+ }
1408
+
1409
+ if (choice === "Undo last file rewind") {
1410
+ const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!);
1411
+ const snapshots = [activeBranchState.undoCommitSha!];
1412
+ const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 };
1413
+ if (restore.undoCommitSha) {
1414
+ data.snapshots.push(restore.undoCommitSha);
1415
+ data.undo = 1;
1416
+ }
1417
+ appendRewindOp(ctx, data);
1418
+ notify(ctx, "Files restored to before last rewind");
1419
+ await reconstructState(ctx);
1420
+ return { cancel: true };
1421
+ }
1422
+
1423
+ if (choice === "Keep current files") {
1424
+ pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() };
1425
+ return;
1426
+ }
1427
+
1428
+ if (!targetCommitSha) {
1429
+ notify(ctx, "Exact file rewind is only available for user, assistant, custom message, compaction, and summary nodes", "error");
1430
+ return { cancel: true };
1431
+ }
1432
+
1433
+ const restore = await restoreCommitExactly(targetCommitSha);
1434
+ pendingTreeState = {
1435
+ currentCommitSha: targetCommitSha,
1436
+ undoCommitSha: restore.undoCommitSha,
1437
+ };
1438
+ notify(ctx, "Files restored to rewind point");
1439
+ } catch (error) {
1440
+ pendingTreeState = null;
1441
+ notify(ctx, `Rewind failed before tree navigation: ${error instanceof Error ? error.message : String(error)}`, "error");
1442
+ return { cancel: true };
1443
+ }
1444
+ });
1445
+ }