@selesai/code 0.9.10 → 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 (302) hide show
  1. package/CHANGELOG.md +260 -255
  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 +96 -35
  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/pi-intercom/LICENSE +21 -21
  43. package/dist/extensions/pi-intercom/broker/client.test.ts +83 -83
  44. package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -387
  45. package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -114
  46. package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -153
  47. package/dist/extensions/pi-intercom/broker/paths.ts +134 -134
  48. package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -34
  49. package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -21
  50. package/dist/extensions/pi-intercom/cwd.test.ts +40 -40
  51. package/dist/extensions/pi-intercom/cwd.ts +31 -31
  52. package/dist/extensions/pi-intercom/extension-api.ts +44 -44
  53. package/dist/extensions/pi-intercom/format-context.test.ts +31 -31
  54. package/dist/extensions/pi-intercom/format-context.ts +32 -32
  55. package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -66
  56. package/dist/extensions/pi-intercom/ui/compose.ts +143 -143
  57. package/dist/extensions/pi-intercom/ui/session-list.ts +166 -166
  58. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +286 -286
  59. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +108 -108
  60. package/dist/extensions/pi-powerline-footer/separators.ts +57 -57
  61. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +47 -47
  62. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -39
  63. package/dist/extensions/pi-powerline-footer/theme.example.json +24 -24
  64. package/dist/extensions/pi-powerline-footer/theme.json +12 -12
  65. package/dist/extensions/pi-powerline-footer/tps.ts +345 -345
  66. package/dist/extensions/pi-rewind-hook/README.md +245 -245
  67. package/dist/extensions/pi-rewind-hook/index.ts +1445 -1445
  68. package/dist/extensions/pi-rewind-hook/package.json +29 -29
  69. package/dist/extensions/pi-subagents/install.mjs +0 -0
  70. package/dist/extensions/pi-web-agent/package.json +31 -31
  71. package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -205
  72. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -136
  73. package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -152
  74. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -25
  75. package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -28
  76. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +136 -136
  77. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -946
  78. package/dist/extensions/pi-web-agent/src/extension.ts +126 -126
  79. package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -118
  80. package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -199
  81. package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -100
  82. package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -117
  83. package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -67
  84. package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -60
  85. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -50
  86. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -52
  87. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -105
  88. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -45
  89. package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -28
  90. package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -47
  91. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -376
  92. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -64
  93. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -181
  94. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -101
  95. package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -81
  96. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +210 -210
  97. package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -75
  98. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -61
  99. package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -54
  100. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -41
  101. package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -20
  102. package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -63
  103. package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -114
  104. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -72
  105. package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -96
  106. package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -71
  107. package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -31
  108. package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -31
  109. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -157
  110. package/dist/extensions/pi-web-agent/src/types.ts +88 -88
  111. package/dist/extensions/ponytail/package.json +8 -8
  112. package/dist/extensions/question/batch.ts +103 -103
  113. package/dist/extensions/question/constants.ts +30 -30
  114. package/dist/extensions/question/helpers.ts +58 -58
  115. package/dist/extensions/question/navigation.ts +14 -14
  116. package/dist/extensions/question/package.json +19 -19
  117. package/dist/extensions/question/schemas.ts +43 -43
  118. package/dist/extensions/question/selection-mode.ts +46 -46
  119. package/dist/extensions/question/shortcuts.ts +44 -44
  120. package/dist/extensions/question/types.ts +132 -132
  121. package/dist/extensions/test-resolve-hook-impl.mjs +6 -6
  122. package/dist/extensions/test-resolve-hook.mjs +6 -6
  123. package/dist/extensions/web-agent-onboarding.ts +222 -222
  124. package/dist/extensions/workflow/package.json +17 -17
  125. package/dist/package-manager-cli.js +71 -71
  126. package/dist/rpc-entry.js +0 -0
  127. package/dist/skills/agent-browser/SKILL.md +52 -52
  128. package/dist/skills/batch-grill-me/SKILL.md +19 -19
  129. package/dist/skills/grill-me/SKILL.md +10 -10
  130. package/dist/skills/handoff/SKILL.md +16 -16
  131. package/dist/skills/handoff-text/SKILL.md +14 -14
  132. package/dist/skills/implanger/SKILL.md +69 -69
  133. package/dist/skills/improve-codebase/REFERENCE.md +78 -78
  134. package/dist/skills/improve-codebase/SKILL.md +178 -178
  135. package/dist/skills/planger/SKILL.md +166 -166
  136. package/dist/skills/ponytail/SKILL.md +116 -116
  137. package/dist/skills/ponytail-audit/SKILL.md +42 -42
  138. package/dist/skills/ponytail-debt/SKILL.md +45 -45
  139. package/dist/skills/ponytail-gain/SKILL.md +51 -51
  140. package/dist/skills/ponytail-help/SKILL.md +70 -70
  141. package/dist/skills/ponytail-review/SKILL.md +58 -58
  142. package/dist/skills/selesai-handoff/SKILL.md +20 -20
  143. package/dist/skills/workflow-creation/SKILL.md +73 -73
  144. package/dist/themes/powerline-footer/theme.json +33 -33
  145. package/dist/utils/shell.d.ts +3 -0
  146. package/dist/utils/shell.js +17 -5
  147. package/docs/compaction.md +396 -396
  148. package/docs/containerization.md +111 -111
  149. package/docs/development.md +71 -71
  150. package/docs/docs.json +164 -164
  151. package/docs/environment-variables.md +86 -86
  152. package/docs/index.md +83 -83
  153. package/docs/json.md +82 -82
  154. package/docs/models.md +502 -502
  155. package/docs/packages.md +227 -227
  156. package/docs/plans/subagent-delegation/phase-0-correctness.md +264 -264
  157. package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +485 -485
  158. package/docs/plans/subagent-delegation/phase-2-context-controls.md +281 -281
  159. package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +361 -361
  160. package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +380 -380
  161. package/docs/prompt-templates.md +95 -95
  162. package/docs/providers.md +293 -293
  163. package/docs/sdk.md +1144 -1143
  164. package/docs/security.md +59 -59
  165. package/docs/session-format.md +414 -414
  166. package/docs/sessions.md +145 -145
  167. package/docs/shared-host-extensions.md +109 -109
  168. package/docs/shell-aliases.md +13 -13
  169. package/docs/skills.md +231 -231
  170. package/docs/terminal-setup.md +142 -142
  171. package/docs/termux.md +127 -127
  172. package/docs/themes.md +295 -295
  173. package/docs/tmux.md +63 -63
  174. package/docs/tui.md +927 -927
  175. package/docs/windows.md +17 -17
  176. package/examples/README.md +25 -25
  177. package/examples/extensions/README.md +211 -211
  178. package/examples/extensions/auto-commit-on-exit.ts +49 -49
  179. package/examples/extensions/bash-spawn-hook.ts +30 -30
  180. package/examples/extensions/bookmark.ts +50 -50
  181. package/examples/extensions/border-status-editor.ts +150 -150
  182. package/examples/extensions/built-in-tool-renderer.ts +249 -249
  183. package/examples/extensions/claude-rules.ts +86 -86
  184. package/examples/extensions/commands.ts +72 -72
  185. package/examples/extensions/confirm-destructive.ts +59 -59
  186. package/examples/extensions/custom-compaction.ts +130 -130
  187. package/examples/extensions/custom-footer.ts +64 -64
  188. package/examples/extensions/custom-header.ts +73 -73
  189. package/examples/extensions/custom-provider-anthropic/index.ts +610 -610
  190. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -24
  191. package/examples/extensions/custom-provider-anthropic/package.json +19 -19
  192. package/examples/extensions/custom-provider-gitlab-duo/index.ts +404 -404
  193. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -16
  194. package/examples/extensions/custom-provider-gitlab-duo/test.ts +82 -82
  195. package/examples/extensions/dirty-repo-guard.ts +56 -56
  196. package/examples/extensions/doom-overlay/README.md +46 -46
  197. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -21
  198. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  199. package/examples/extensions/doom-overlay/doom/build.sh +152 -152
  200. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -72
  201. package/examples/extensions/doom-overlay/doom-component.ts +132 -132
  202. package/examples/extensions/doom-overlay/doom-engine.ts +173 -173
  203. package/examples/extensions/doom-overlay/doom-keys.ts +104 -104
  204. package/examples/extensions/doom-overlay/index.ts +74 -74
  205. package/examples/extensions/doom-overlay/wad-finder.ts +51 -51
  206. package/examples/extensions/dynamic-resources/SKILL.md +8 -8
  207. package/examples/extensions/dynamic-resources/dynamic.json +79 -79
  208. package/examples/extensions/dynamic-resources/dynamic.md +5 -5
  209. package/examples/extensions/dynamic-resources/index.ts +15 -15
  210. package/examples/extensions/dynamic-tools.ts +74 -74
  211. package/examples/extensions/event-bus.ts +43 -43
  212. package/examples/extensions/file-trigger.ts +41 -41
  213. package/examples/extensions/git-checkpoint.ts +53 -53
  214. package/examples/extensions/git-merge-and-resolve.ts +115 -115
  215. package/examples/extensions/github-issue-autocomplete.ts +185 -185
  216. package/examples/extensions/gondolin/index.ts +531 -531
  217. package/examples/extensions/gondolin/package-lock.json +185 -185
  218. package/examples/extensions/gondolin/package.json +19 -19
  219. package/examples/extensions/handoff.ts +199 -199
  220. package/examples/extensions/hello.ts +26 -26
  221. package/examples/extensions/hidden-thinking-label.ts +53 -53
  222. package/examples/extensions/inline-bash.ts +94 -94
  223. package/examples/extensions/input-transform-streaming.ts +39 -39
  224. package/examples/extensions/input-transform.ts +43 -43
  225. package/examples/extensions/interactive-shell.ts +196 -196
  226. package/examples/extensions/mac-system-theme.ts +47 -47
  227. package/examples/extensions/message-renderer.ts +59 -59
  228. package/examples/extensions/minimal-mode.ts +426 -426
  229. package/examples/extensions/modal-editor.ts +85 -85
  230. package/examples/extensions/model-status.ts +31 -31
  231. package/examples/extensions/notify.ts +55 -55
  232. package/examples/extensions/overlay-qa-tests.ts +1450 -1450
  233. package/examples/extensions/overlay-test.ts +153 -153
  234. package/examples/extensions/permission-gate.ts +34 -34
  235. package/examples/extensions/pirate.ts +47 -47
  236. package/examples/extensions/plan-mode/README.md +66 -66
  237. package/examples/extensions/plan-mode/index.ts +390 -390
  238. package/examples/extensions/plan-mode/utils.ts +168 -168
  239. package/examples/extensions/preset.ts +436 -436
  240. package/examples/extensions/project-trust.ts +64 -64
  241. package/examples/extensions/prompt-customizer.ts +97 -97
  242. package/examples/extensions/protected-paths.ts +30 -30
  243. package/examples/extensions/provider-payload.ts +18 -18
  244. package/examples/extensions/qna.ts +122 -122
  245. package/examples/extensions/question.ts +285 -285
  246. package/examples/extensions/questionnaire.ts +448 -448
  247. package/examples/extensions/rainbow-editor.ts +88 -88
  248. package/examples/extensions/reload-runtime.ts +37 -37
  249. package/examples/extensions/rpc-demo.ts +118 -118
  250. package/examples/extensions/sandbox/index.ts +321 -321
  251. package/examples/extensions/sandbox/package-lock.json +92 -92
  252. package/examples/extensions/sandbox/package.json +19 -19
  253. package/examples/extensions/send-user-message.ts +97 -97
  254. package/examples/extensions/session-name.ts +27 -27
  255. package/examples/extensions/shutdown-command.ts +63 -63
  256. package/examples/extensions/snake.ts +343 -343
  257. package/examples/extensions/space-invaders.ts +560 -560
  258. package/examples/extensions/ssh.ts +220 -220
  259. package/examples/extensions/status-line.ts +32 -32
  260. package/examples/extensions/structured-output.ts +65 -65
  261. package/examples/extensions/subagent/README.md +175 -175
  262. package/examples/extensions/subagent/agents/planner.md +37 -37
  263. package/examples/extensions/subagent/agents/reviewer.md +35 -35
  264. package/examples/extensions/subagent/agents/scout.md +50 -50
  265. package/examples/extensions/subagent/agents/worker.md +24 -24
  266. package/examples/extensions/subagent/agents.ts +126 -126
  267. package/examples/extensions/subagent/index.ts +1015 -1015
  268. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -10
  269. package/examples/extensions/subagent/prompts/implement.md +10 -10
  270. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -9
  271. package/examples/extensions/summarize.ts +209 -209
  272. package/examples/extensions/system-prompt-header.ts +17 -17
  273. package/examples/extensions/tic-tac-toe.ts +1008 -1008
  274. package/examples/extensions/timed-confirm.ts +70 -70
  275. package/examples/extensions/titlebar-spinner.ts +58 -58
  276. package/examples/extensions/todo.ts +297 -297
  277. package/examples/extensions/tool-override.ts +144 -144
  278. package/examples/extensions/tools.ts +146 -146
  279. package/examples/extensions/trigger-compact.ts +50 -50
  280. package/examples/extensions/truncated-tool.ts +195 -195
  281. package/examples/extensions/widget-placement.ts +9 -9
  282. package/examples/extensions/with-deps/index.ts +32 -32
  283. package/examples/extensions/with-deps/package-lock.json +31 -31
  284. package/examples/extensions/with-deps/package.json +22 -22
  285. package/examples/extensions/working-indicator.ts +123 -123
  286. package/examples/extensions/working-message-test.ts +25 -25
  287. package/examples/rpc-extension-ui.ts +632 -632
  288. package/examples/sdk/01-minimal.ts +26 -26
  289. package/examples/sdk/02-custom-model.ts +53 -53
  290. package/examples/sdk/03-custom-prompt.ts +75 -75
  291. package/examples/sdk/04-skills.ts +55 -55
  292. package/examples/sdk/05-tools.ts +48 -48
  293. package/examples/sdk/06-extensions.ts +99 -99
  294. package/examples/sdk/07-context-files.ts +47 -47
  295. package/examples/sdk/08-prompt-templates.ts +51 -51
  296. package/examples/sdk/09-api-keys-and-oauth.ts +52 -52
  297. package/examples/sdk/10-settings.ts +53 -53
  298. package/examples/sdk/11-sessions.ts +52 -52
  299. package/examples/sdk/12-full-control.ts +79 -79
  300. package/examples/sdk/13-session-runtime.ts +67 -67
  301. package/examples/sdk/README.md +144 -144
  302. 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
+ }