mixdog 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -1
  3. package/scripts/lib/run-node-tests.mjs +9 -0
  4. package/scripts/prune-desktop-runtime.mjs +3 -0
  5. package/scripts/test-direct.mjs +4 -2
  6. package/src/defaults/shipped-history.json +6 -0
  7. package/src/defaults/skills/setup/references/actions.md +5 -2
  8. package/src/defaults/skills/visualize/SKILL.md +157 -0
  9. package/src/defaults/skills/visualize/references/craft.md +88 -0
  10. package/src/headless-command.mjs +56 -3
  11. package/src/headless-exec.mjs +17 -1
  12. package/src/help.mjs +2 -0
  13. package/src/rules/shared/70-delivery.md +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
  17. package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
  18. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
  19. package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
  20. package/src/runtime/agent/orchestrator/config.mjs +9 -1
  21. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +47 -7
  22. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +51 -8
  23. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +25 -5
  24. package/src/runtime/agent/orchestrator/mcp/client.mjs +348 -122
  25. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +13 -1
  29. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
  30. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
  32. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +12 -4
  33. package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
  34. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
  37. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
  38. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
  39. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
  40. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +67 -0
  41. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +521 -0
  42. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
  43. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
  44. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
  45. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +15 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
  53. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +12 -11
  54. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +11 -0
  56. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
  57. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
  58. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
  60. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +18 -11
  62. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
  63. package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
  64. package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
  65. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
  66. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
  69. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -1
  70. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +1 -0
  71. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -2
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -0
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +5 -3
  76. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
  78. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
  79. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
  80. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
  81. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
  82. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
  83. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
  84. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
  85. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  86. package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
  87. package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
  88. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +29 -19
  89. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
  90. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
  91. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
  92. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +29 -0
  93. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -1
  94. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +23 -4
  95. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
  96. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +28 -8
  97. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +140 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
  99. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
  100. package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +31 -12
  103. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -66
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -1
  109. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
  110. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  111. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
  113. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
  114. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
  115. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
  116. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
  118. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
  119. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
  120. package/src/runtime/attachments/store.mjs +38 -0
  121. package/src/runtime/shared/atomic-file.mjs +8 -2
  122. package/src/runtime/shared/best-effort-stderr.mjs +8 -0
  123. package/src/runtime/shared/code-graph-state.mjs +12 -0
  124. package/src/runtime/shared/defaults-separation.mjs +2 -0
  125. package/src/runtime/shared/git-runner-worker.mjs +3 -2
  126. package/src/runtime/shared/git-runner.mjs +4 -3
  127. package/src/runtime/shared/llm/default-context-window.mjs +6 -0
  128. package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
  129. package/src/runtime/shared/llm/model-catalog.mjs +240 -118
  130. package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
  131. package/src/runtime/shared/llm/provider-catalog-registry.mjs +156 -0
  132. package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
  133. package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
  134. package/src/runtime/shared/model-proxy-mode.mjs +19 -0
  135. package/src/runtime/shared/provider-accounts.mjs +2 -2
  136. package/src/runtime/shared/provider-api-key.mjs +21 -1
  137. package/src/runtime/shared/schedule-model-ref.mjs +11 -2
  138. package/src/runtime/shared/session-usage-totals.mjs +19 -0
  139. package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
  140. package/src/runtime/shared/singleton-owner.mjs +36 -6
  141. package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
  142. package/src/runtime/shared/tool-surface.mjs +7 -0
  143. package/src/runtime/shared/tool-work-units.mjs +14 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +17 -1
  145. package/src/runtime/shared/turn-changes.mjs +791 -0
  146. package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
  147. package/src/runtime/shared/turn-snapshot.mjs +690 -81
  148. package/src/runtime/shared/turn-worktree-snapshot.mjs +161 -11
  149. package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
  150. package/src/runtime/shared/worktrees/contract.mjs +551 -0
  151. package/src/runtime/shared/worktrees/disk.mjs +57 -0
  152. package/src/runtime/shared/worktrees/git.mjs +247 -0
  153. package/src/runtime/shared/worktrees/include.mjs +480 -0
  154. package/src/runtime/shared/worktrees/lock.mjs +46 -0
  155. package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
  156. package/src/runtime/shared/worktrees/manager.mjs +1215 -0
  157. package/src/runtime/shared/worktrees/merge.mjs +182 -0
  158. package/src/runtime/shared/worktrees/owner.mjs +46 -0
  159. package/src/runtime/shared/worktrees/paths.mjs +62 -0
  160. package/src/runtime/shared/worktrees/registry.mjs +110 -0
  161. package/src/runtime/shared/worktrees/settings.mjs +84 -0
  162. package/src/runtime/shared/worktrees/slug.mjs +62 -0
  163. package/src/runtime/shared/worktrees/test-support.mjs +96 -0
  164. package/src/runtime/visualize-bridge/client.mjs +181 -0
  165. package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
  166. package/src/session-runtime/boot/apis.mjs +35 -0
  167. package/src/session-runtime/boot/config.mjs +2 -0
  168. package/src/session-runtime/boot/facade.mjs +20 -0
  169. package/src/session-runtime/boot/infrastructure.mjs +1 -0
  170. package/src/session-runtime/boot/session.mjs +1 -0
  171. package/src/session-runtime/boot/tools.mjs +4 -0
  172. package/src/session-runtime/config-lifecycle.mjs +10 -1
  173. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +11 -1
  174. package/src/session-runtime/global-extensions.mjs +2 -2
  175. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
  176. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
  177. package/src/session-runtime/internal-tool-executor.mjs +9 -1
  178. package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
  179. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
  180. package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
  181. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
  182. package/src/session-runtime/mcp-glue/connect.mjs +11 -6
  183. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -3
  184. package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
  185. package/src/session-runtime/model-route/route-persist.mjs +3 -0
  186. package/src/session-runtime/model-route/set-route.mjs +15 -2
  187. package/src/session-runtime/notification-bus.mjs +9 -1
  188. package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
  189. package/src/session-runtime/resource-extension-sync.mjs +30 -7
  190. package/src/session-runtime/resource-mcp-api.mjs +20 -7
  191. package/src/session-runtime/route-resolution.mjs +10 -1
  192. package/src/session-runtime/route-state.mjs +16 -2
  193. package/src/session-runtime/runtime-core.mjs +5 -0
  194. package/src/session-runtime/runtime-facade.mjs +8 -0
  195. package/src/session-runtime/runtime-review-api.mjs +9 -0
  196. package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
  197. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
  198. package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
  199. package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
  200. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
  201. package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
  202. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
  203. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
  204. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
  205. package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
  206. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
  207. package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
  208. package/src/session-runtime/services/agent-tool.mjs +1 -0
  209. package/src/session-runtime/services/daemon-candidate.mjs +3 -2
  210. package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
  211. package/src/session-runtime/session-create/session-options.mjs +10 -1
  212. package/src/session-runtime/session-turn-api.mjs +1 -0
  213. package/src/session-runtime/settings-api.mjs +4 -2
  214. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
  215. package/src/session-runtime/settings-worktree-api.mjs +17 -0
  216. package/src/session-runtime/setup-tool/executor.mjs +11 -4
  217. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -1
  218. package/src/session-runtime/setup-tool/tool-defs.mjs +31 -3
  219. package/src/session-runtime/turn/agent-ops.mjs +12 -1
  220. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +13 -3
  221. package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
  222. package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
  223. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
  224. package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
  225. package/src/session-runtime/turn/turn-run.mjs +6 -7
  226. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
  227. package/src/session-runtime/workspace/admission-gate.mjs +197 -0
  228. package/src/session-runtime/workspace/project-branches.mjs +136 -0
  229. package/src/session-runtime/workspace/transition-steps.mjs +364 -0
  230. package/src/session-runtime/workspace/transitions.mjs +651 -0
  231. package/src/session-runtime/workspace/workspace-state.mjs +112 -0
  232. package/src/standalone/channel-transport/http-routes.mjs +17 -0
  233. package/src/standalone/channel-transport.mjs +13 -1
  234. package/src/standalone/daemon-desktop-runtime.mjs +1 -0
  235. package/src/standalone/daemon-owner.mjs +372 -0
  236. package/src/standalone/daemon-stored-session-views.mjs +4 -1
  237. package/src/standalone/daemon.mjs +139 -5
  238. package/src/standalone/desktop-service-registry.mjs +20 -0
  239. package/src/standalone/local-session-runtime.mjs +11 -0
  240. package/src/standalone/session-client/attachment-calls.mjs +3 -1
  241. package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
  242. package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
  243. package/src/standalone/session-client.mjs +104 -13
  244. package/src/standalone/session-protocol.mjs +16 -0
  245. package/src/standalone/session-proxy.mjs +3 -0
  246. package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
  247. package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
  248. package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
  249. package/src/standalone/session-service/projection/revisions.mjs +2 -0
  250. package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
  251. package/src/standalone/session-service/projection/workspace.mjs +14 -0
  252. package/src/standalone/session-service/session-calls/session-action.mjs +11 -3
  253. package/src/standalone/session-transport/call-route.mjs +13 -0
  254. package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
  255. package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
  256. package/src/standalone/session-transport/client-registry.mjs +20 -3
  257. package/src/standalone/session-transport/routes.mjs +79 -2
  258. package/src/standalone/session-transport/stack.mjs +2 -0
  259. package/src/standalone/session-transport.mjs +24 -1
  260. package/src/tui/app/message-selector.mjs +2 -1
  261. package/src/tui/app/model-options.mjs +35 -5
  262. package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
  263. package/src/tui/app/model-picker.mjs +4 -2
  264. package/src/tui/dist/index.mjs +332 -36
  265. package/src/tui/index.jsx +10 -2
  266. package/src/tui/session/compaction-view.mjs +147 -0
  267. package/src/tui/session/context-state.mjs +6 -0
  268. package/src/tui/session/initial-state.mjs +3 -0
  269. package/src/tui/session/quota-resume.mjs +2 -1
  270. package/src/tui/session/session-api/commands.mjs +10 -2
  271. package/src/tui/session/session-api/controls.mjs +4 -0
  272. package/src/tui/session/session-api/intake/rewind.mjs +2 -1
  273. package/src/tui/session/session-api/integrations.mjs +17 -0
  274. package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
  275. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
  276. package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
  277. package/src/tui/session/session-api/routes.mjs +10 -1
  278. package/src/tui/session/session-api/settings.mjs +2 -0
  279. package/src/tui/session/session-api/worktree.mjs +60 -0
  280. package/src/tui/session/session-api.mjs +2 -0
  281. package/src/tui/session/session-flow/auto-clear.mjs +31 -12
  282. package/src/tui/session/session-flow/drain.mjs +15 -1
  283. package/src/tui/session/session-flow/session-reset.mjs +4 -3
  284. package/src/tui/session/session-flow.mjs +2 -0
  285. package/src/tui/session/transcript-intake.mjs +14 -5
  286. package/src/tui/session/turn-stream.mjs +32 -4
  287. package/src/tui/session/turn-tool-cards.mjs +10 -2
  288. package/src/tui/session/turn.mjs +39 -10
@@ -4,6 +4,8 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path';
4
4
  import {
5
5
  _resetTurnWorktreeSnapshotsForTest,
6
6
  createTurnWorktreeSnapshot,
7
+ freezeTurnWorktreeChanges,
8
+ noteTurnWorktreeChange,
7
9
  refreshTurnWorktreeSnapshot,
8
10
  recordTurnWorktreeFileChange,
9
11
  resumeTurnWorktreeSnapshot,
@@ -11,7 +13,27 @@ import {
11
13
  revertTurnWorktreePaths,
12
14
  revertTurnWorktreeSnapshot,
13
15
  } from './turn-worktree-snapshot.mjs';
14
- import { loadSessionSnapshotRecords, loadTurnSnapshotRecord, saveTurnSnapshotRecord } from './turn-snapshot-store.mjs';
16
+ import {
17
+ loadSessionSnapshotRecords,
18
+ loadTurnChangeRecord,
19
+ loadTurnChangeRecords,
20
+ loadTurnSnapshotRecord,
21
+ saveTurnSnapshotRecord,
22
+ updateTurnChangeRecord,
23
+ upsertTurnChangeRecord,
24
+ } from './turn-snapshot-store.mjs';
25
+ import {
26
+ applyTurnChangeFiles,
27
+ changePathKey,
28
+ findChangeFile,
29
+ measureLegacyChangeFile,
30
+ mergeRecordedSessionChanges,
31
+ namedChangePatch,
32
+ storedChangeFile,
33
+ trackedChangeFile,
34
+ turnChangesReply,
35
+ unknownStateChangeFile,
36
+ } from './turn-changes.mjs';
15
37
  import { boundReviewPatch } from './review-diff.mjs';
16
38
  import { PATCH_EDIT_MAX_BYTES } from './bounded-file-read.mjs';
17
39
  import { cleanString as clean } from './clean.mjs';
@@ -39,6 +61,14 @@ const MAX_AGENT_REVIEW_BYTES_PER_TURN = 4 * 1024 * 1024;
39
61
  const _turnsBySession = new Map();
40
62
  const _diffTrackersBySession = new Map();
41
63
  let _agentTurnSeq = 0;
64
+ // `${sessionId}\0${checkpointId}` → seals in flight. A sealing turn's change
65
+ // record is `pending` and its session refuses Undo/Reapply until it lands.
66
+ const _pendingFreezes = new Map();
67
+ // Per-session Undo/Reapply chain.
68
+ const _changeMutations = new Map();
69
+ // `${sessionId}\0${checkpointId}` → path keys whose worktree state is unknown
70
+ // and whose record could not even be marked so (the store refused every write).
71
+ const _unknownChangeStates = new Map();
42
72
 
43
73
  function trimTurnCache() {
44
74
  while (_turnsBySession.size > TURN_CACHE_MAX) {
@@ -165,9 +195,16 @@ function createDiffTracker(meta = {}) {
165
195
  ownerGeneration: Number(meta.ownerGeneration) || 0,
166
196
  checkpointId: clean(meta.checkpointId),
167
197
  worktreeRequest: clean(meta.worktreeRequest),
198
+ // The directory the turn ran in: a non-Git record's patch root.
199
+ worktreeRoot: clean(meta.worktreeRoot),
168
200
  worktreeContended: false,
169
201
  valid: true,
170
202
  sealed: false,
203
+ // Bumped when a completion wake reopens the sealed turn, so a seal that
204
+ // started before the reopen cannot mark the reopened turn sealed.
205
+ sealEpoch: 0,
206
+ // The seal (refresh, record, freeze) in flight, if any.
207
+ freezing: null,
171
208
  revision: 0,
172
209
  baselineByPath: new Map(),
173
210
  currentByPath: new Map(),
@@ -417,6 +454,8 @@ async function revertTrackedPairs(tracker, worktree, pairs) {
417
454
  tracker.revision += 1;
418
455
  refreshUnifiedDiff(tracker);
419
456
  if (!tracker.unifiedDiff) releaseDiffTrackerContent(tracker);
457
+ // The absolute paths now back at their turn-start bytes.
458
+ return [...new Set(validated.flatMap(({ beforePath, afterPath }) => [beforePath, afterPath]))];
420
459
  }
421
460
 
422
461
  /** A path's first observed state is its baseline; later changes never replace it. */
@@ -435,7 +474,16 @@ export function recordTurnDiffChanges(sessionId, changes = []) {
435
474
  if (!id) return '';
436
475
  const tracker = _diffTrackersBySession.get(id) || resetDiffTracker(id);
437
476
  if (!tracker?.valid || tracker.sealed) return tracker?.unifiedDiff || '';
438
- const list = Array.isArray(changes) ? changes : [];
477
+ const snapshot = (tracker.ownerSessionId ? _diffTrackersBySession.get(tracker.ownerSessionId) : tracker)
478
+ ?.worktreeSnapshot;
479
+ foldTrackedChanges(tracker, Array.isArray(changes) ? changes : [], snapshot);
480
+ return tracker.unifiedDiff;
481
+ }
482
+
483
+ /** Fold exact before/after states into `tracker`. `snapshot` — the owning
484
+ * Lead turn's Git baseline, when tools are writing — learns each written
485
+ * path's first-before bytes. */
486
+ function foldTrackedChanges(tracker, list, snapshot) {
439
487
  // The content ceiling is enforced BEFORE anything is stored: an over-budget
440
488
  // batch invalidates the tracker up front instead of retaining its buffers.
441
489
  const incomingBytes = list.reduce(
@@ -456,8 +504,6 @@ export function recordTurnDiffChanges(sessionId, changes = []) {
456
504
  // batch only hands the worktree baseline a bounded value.
457
505
  const before = storeContent ? contentBuffer(raw?.before) : boundedBaselineContent(raw?.before);
458
506
  const destinationPath = clean(raw?.newPath);
459
- const snapshot = (tracker.ownerSessionId ? _diffTrackersBySession.get(tracker.ownerSessionId) : tracker)
460
- ?.worktreeSnapshot;
461
507
  recordTurnWorktreeFileChange(snapshot, sourcePath, before);
462
508
  if (destinationPath) recordTurnWorktreeFileChange(snapshot, destinationPath, null);
463
509
  // WHICH files this session wrote has to survive releasing their content —
@@ -522,7 +568,6 @@ export function recordTurnDiffChanges(sessionId, changes = []) {
522
568
  releaseDiffTrackerContent(tracker);
523
569
  }
524
570
  refreshUnifiedDiff(tracker);
525
- return tracker.unifiedDiff;
526
571
  }
527
572
 
528
573
  function trimAgentReviews(turn) {
@@ -569,18 +614,59 @@ function continueTurnSnapshot(ownerSessionId) {
569
614
  _turnsBySession.delete(ownerSessionId);
570
615
  _turnsBySession.set(ownerSessionId, turn);
571
616
  tracker.sealed = false;
617
+ tracker.sealEpoch += 1;
572
618
  markWorktreeContention(ownerSessionId, tracker);
573
619
  return true;
574
620
  }
575
621
 
576
622
  /** Start a new user turn and invalidate the prior turn's child review group.
577
- * `continueTurn` (a completion wake) reopens the current turn instead. */
623
+ * `continueTurn` (a completion wake) reopens the current turn instead.
624
+ * Resolves to the turn's `{ generation, checkpointId }`: completing with it
625
+ * can only ever seal this turn, never a newer one. */
578
626
  export async function beginTurnSnapshot(worktree, sessionId, options = {}) {
579
627
  const ownerSessionId = clean(sessionId);
580
- if (DISABLED || !ownerSessionId) return;
581
- if (options?.continueTurn === true && continueTurnSnapshot(ownerSessionId)) return;
628
+ if (DISABLED || !ownerSessionId) return undefined;
629
+ // Turn start and Undo/Reapply share the per-session lock: a start waits for
630
+ // an in-flight mutation, and a mutation queued after this start re-checks
631
+ // under the lock and finds the turn running.
632
+ // A late-worker re-seal still queued for the previous turn runs first, in
633
+ // this same step: the worker's edit stays in that turn's record and nothing
634
+ // the new turn writes can reach it. (A queued re-seal implies a chain.)
635
+ const { token, tracker } = _changeMutations.has(ownerSessionId)
636
+ ? await serializeChangeMutation(ownerSessionId, async () => {
637
+ const previous = _diffTrackersBySession.get(ownerSessionId);
638
+ if (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
639
+ return openTurnSnapshot(worktree, ownerSessionId, options);
640
+ })
641
+ : openTurnSnapshot(worktree, ownerSessionId, options);
642
+ if (!tracker) return token;
643
+ try {
644
+ const snapshot = await createTurnWorktreeSnapshot(worktree);
645
+ if (_diffTrackersBySession.get(ownerSessionId) === tracker) {
646
+ tracker.worktreeSnapshot = snapshot;
647
+ if (snapshot?.root) markWorktreeContention(ownerSessionId, tracker);
648
+ }
649
+ } catch {
650
+ // Git is optional. Exact apply_patch tracking remains the fallback.
651
+ }
652
+ return token;
653
+ }
654
+
655
+ /** The synchronous half of a turn start: reopen (completion wake) or install
656
+ * the turn's tracker. `tracker` is set only for a new turn, whose Git
657
+ * baseline the caller captures next. */
658
+ function openTurnSnapshot(worktree, ownerSessionId, options) {
659
+ if (options?.continueTurn === true && continueTurnSnapshot(ownerSessionId)) {
660
+ const reopened = _diffTrackersBySession.get(ownerSessionId);
661
+ return { token: { generation: reopened.ownerGeneration, checkpointId: reopened.checkpointId }, tracker: null };
662
+ }
582
663
  const generation = (_turnsBySession.get(ownerSessionId)?.generation || 0) + 1;
583
664
  const checkpointId = clean(options?.checkpointId);
665
+ // A turn replaced before it sealed (an abort whose delayed cleanup has not
666
+ // run yet) still keeps its edits as a record. Its capture is queued on the
667
+ // worktree lock ahead of this turn's baseline.
668
+ const prior = _diffTrackersBySession.get(ownerSessionId);
669
+ if (prior && prior.ownerGeneration > 0 && !prior.sealed) void sealTurn(ownerSessionId, prior).catch(() => {});
584
670
  for (const [trackedSessionId, tracker] of _diffTrackersBySession) {
585
671
  if (tracker.ownerSessionId === ownerSessionId) _diffTrackersBySession.delete(trackedSessionId);
586
672
  }
@@ -595,19 +681,19 @@ export async function beginTurnSnapshot(worktree, sessionId, options = {}) {
595
681
  ownerGeneration: generation,
596
682
  checkpointId,
597
683
  worktreeRequest,
684
+ worktreeRoot: resolve(clean(worktree) || '.'),
598
685
  });
599
686
  trimTurnCache();
600
- if (!tracker) return;
601
- markWorktreeContention(ownerSessionId, tracker);
602
- try {
603
- const snapshot = await createTurnWorktreeSnapshot(worktree);
604
- if (_diffTrackersBySession.get(ownerSessionId) === tracker) {
605
- tracker.worktreeSnapshot = snapshot;
606
- if (snapshot?.root) markWorktreeContention(ownerSessionId, tracker);
607
- }
608
- } catch {
609
- // Git is optional. Exact apply_patch tracking remains the fallback.
610
- }
687
+ if (tracker) markWorktreeContention(ownerSessionId, tracker);
688
+ _turnOpenedHookForTest?.(ownerSessionId, checkpointId);
689
+ return { token: { generation, checkpointId }, tracker };
690
+ }
691
+
692
+ // Test seam: runs synchronously the instant a new turn's tracker is installed,
693
+ // i.e. the earliest moment that turn could touch the worktree.
694
+ let _turnOpenedHookForTest = null;
695
+ export function _setTurnOpenedHookForTest(hook) {
696
+ _turnOpenedHookForTest = typeof hook === 'function' ? hook : null;
611
697
  }
612
698
 
613
699
  /** Bind one worker execution to the Lead turn that launched it. */
@@ -673,6 +759,11 @@ export function completeAgentTurnReview(handle, patches = []) {
673
759
  for (const path of tracker.ownedPaths) ownerTracker.ownedPaths.add(path);
674
760
  }
675
761
  trimAgentReviews(turn);
762
+ // A worker that finishes after its Lead turn sealed re-seals that turn's
763
+ // change record (its edits are part of the turn's net worktree diff).
764
+ if (ownerTracker && ownerTracker !== tracker && ownerTracker.ownerGeneration === handle.generation) {
765
+ resealTurn(clean(handle.ownerSessionId), ownerTracker);
766
+ }
676
767
  return true;
677
768
  } finally {
678
769
  if (matchingTracker && _diffTrackersBySession.get(key) === tracker) {
@@ -694,7 +785,7 @@ async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
694
785
  if (owned.length === 0) return;
695
786
  try {
696
787
  await saveTurnSnapshotRecord(ownerSessionId, {
697
- generation: _turnsBySession.get(ownerSessionId)?.generation || 0,
788
+ generation: tracker.ownerGeneration || _turnsBySession.get(ownerSessionId)?.generation || 0,
698
789
  checkpointId: tracker.checkpointId,
699
790
  root: snapshot.root,
700
791
  baselineTree: snapshot.baselineTree,
@@ -707,39 +798,211 @@ async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
707
798
  }
708
799
  }
709
800
 
710
- /** Freeze a completed Lead turn while retaining only its bounded review data. */
711
- export async function completeTurnSnapshot(sessionId) {
712
- const ownerSessionId = clean(sessionId);
713
- const tracker = _diffTrackersBySession.get(ownerSessionId);
714
- if (!tracker) return false;
801
+ /** Workers attributed to the tracker's own turn (identities only: their
802
+ * patches stay review attribution and never join the net diff). */
803
+ function attributedAgents(ownerSessionId, tracker) {
804
+ const turn = _turnsBySession.get(ownerSessionId);
805
+ if (!turn || turn.generation !== tracker.ownerGeneration) return [];
806
+ return [...turn.agents.values()].map((review) => ({
807
+ sessionId: review.sessionId,
808
+ agent: review.agent || null,
809
+ tag: review.tag || null,
810
+ }));
811
+ }
812
+
813
+ /** Path of a tracked entry relative to `root`, or '' outside it. */
814
+ function trackedRelativePath(root, entry) {
815
+ const value = clean(entry?.path) || clean(entry?.displayPath);
816
+ if (!value) return '';
817
+ const rel = relative(root, isAbsolute(value) ? resolve(value) : resolve(root, value));
818
+ if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return '';
819
+ return rel.replace(/\\/g, '/');
820
+ }
821
+
822
+ /** A record file for an edit the worktree root cannot replay: outside it, or
823
+ * too large/binary to capture. Display-only (`viewPatch`), never undone. */
824
+ function untrackableRecordFile(pair, { before, after, oldPath, newPath, omitted }) {
825
+ let status = 'M';
826
+ if (before === null) status = 'A';
827
+ else if (after === null) status = 'D';
828
+ else if (oldPath !== newPath) status = 'R';
829
+ const shownPath =
830
+ (status === 'D' ? oldPath : newPath) || displayPath(pair.after?.displayPath || pair.before?.displayPath);
831
+ const named = omitted ? null : namedChangePatch({ before, after, oldName: shownPath, newName: shownPath });
832
+ return {
833
+ path: shownPath,
834
+ ...(status === 'R' && oldPath ? { oldPath } : {}),
835
+ status,
836
+ additions: named?.additions || 0,
837
+ deletions: named?.deletions || 0,
838
+ ...(named?.patch ? { viewPatch: named.patch } : {}),
839
+ ...(omitted ? { binary: true } : {}),
840
+ revertable: false,
841
+ reason: omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
842
+ };
843
+ }
844
+
845
+ /** The exact before/after pair of a tracked entry, or null when it changed nothing. */
846
+ function recordedPair(pair, root) {
847
+ const before = pair.before?.content ?? null;
848
+ const after = pair.after?.content ?? null;
849
+ if (before === null && after === null) return null;
850
+ const oldPath = trackedRelativePath(root, pair.before || pair.after);
851
+ const newPath = trackedRelativePath(root, pair.after || pair.before);
852
+ if (oldPath === newPath && sameContent(before, after)) return null;
853
+ return { before, after, oldPath, newPath, omitted: [before, after].some(isOmittedContent) };
854
+ }
855
+
856
+ /** Record files of a non-Git turn, from the exact apply_patch buffers. */
857
+ function trackedRecordFiles(tracker, root) {
858
+ if (!tracker.valid) {
859
+ return [...ownedPathsUnder(root, tracker.ownedPaths)].map((path) => ({
860
+ path,
861
+ status: 'M',
862
+ additions: 0,
863
+ deletions: 0,
864
+ revertable: false,
865
+ reason: 'file content exceeded the undo tracking limit',
866
+ }));
867
+ }
868
+ const files = [];
869
+ for (const pair of trackedPairs(tracker)) {
870
+ const edit = recordedPair(pair, root);
871
+ if (!edit) continue;
872
+ if (edit.omitted || !edit.oldPath || !edit.newPath) {
873
+ files.push(untrackableRecordFile(pair, edit));
874
+ continue;
875
+ }
876
+ files.push(
877
+ trackedChangeFile({ oldPath: edit.oldPath, newPath: edit.newPath, before: edit.before, after: edit.after })
878
+ );
879
+ }
880
+ return files;
881
+ }
882
+
883
+ /** Edits of a Git turn that lie outside its worktree root: the baseline diff
884
+ * never sees them, so they are recorded view-only beside the Git files. */
885
+ function outsideRecordFiles(tracker, root) {
886
+ if (!tracker.valid) return [];
887
+ const files = [];
888
+ for (const pair of trackedPairs(tracker)) {
889
+ const edit = recordedPair(pair, root);
890
+ if (edit && (!edit.oldPath || !edit.newPath)) {
891
+ files.push(untrackableRecordFile(pair, edit));
892
+ }
893
+ }
894
+ return files;
895
+ }
896
+
897
+ /** Freeze the turn's net diff into its durable change record. A Git turn
898
+ * records the baseline→end worktree diff (workers' edits included, scoped to
899
+ * session-owned paths when the worktree is shared); a non-Git turn records
900
+ * the Lead's exact apply_patch pairs. */
901
+ async function freezeTurnChanges(ownerSessionId, tracker, agents) {
902
+ const checkpointId = clean(tracker.checkpointId);
903
+ if (!checkpointId) return null;
904
+ let frozen = null;
715
905
  if (tracker.worktreeSnapshot) {
716
- try {
717
- await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
718
- } catch {}
906
+ const scope = worktreeSnapshotUsable(tracker)
907
+ ? null
908
+ : [...ownedPathsUnder(tracker.worktreeSnapshot.root, tracker.ownedPaths)];
909
+ const result = await freezeTurnWorktreeChanges(tracker.worktreeSnapshot, scope);
910
+ if (result) {
911
+ frozen = { kind: 'git', ...result, outside: outsideRecordFiles(tracker, tracker.worktreeSnapshot.root) };
912
+ }
913
+ } else if (tracker.worktreeRoot) {
914
+ frozen = { kind: 'tracked', root: tracker.worktreeRoot, files: trackedRecordFiles(tracker, tracker.worktreeRoot) };
719
915
  }
720
- tracker.sealed = true;
721
- await persistTurnSnapshotRecord(ownerSessionId, tracker);
722
- // Only a usable Git baseline can replace the exact apply_patch buffers. A
723
- // contended worktree — and a worktree with no Git baseline at all — retains
724
- // this session's bounded before/after content so Review can still undo its
725
- // own files after the turn completes.
726
- if (worktreeSnapshotUsable(tracker)) releaseDiffTrackerContent(tracker);
727
- return true;
916
+ if (!frozen) return null;
917
+ const record = await upsertTurnChangeRecord(ownerSessionId, {
918
+ checkpointId,
919
+ kind: frozen.kind,
920
+ root: frozen.root,
921
+ agents,
922
+ files: frozen.files.map(storedChangeFile),
923
+ outside: (frozen.outside || []).map(storedChangeFile),
924
+ });
925
+ // A fresh freeze measured the disk itself; nothing about it is unknown.
926
+ _unknownChangeStates.delete(pendingFreezeKey(ownerSessionId, checkpointId));
927
+ return record;
728
928
  }
729
929
 
730
- /** Abandon an aborted Lead turn without waiting for optional Git snapshot IO. */
731
- export function cancelTurnSnapshot(sessionId) {
732
- const ownerSessionId = clean(sessionId);
733
- if (!ownerSessionId) return false;
734
- let removed = false;
735
- for (const [trackedSessionId, tracker] of _diffTrackersBySession) {
736
- if (trackedSessionId !== ownerSessionId && tracker.ownerSessionId !== ownerSessionId) continue;
737
- releaseDiffTrackerContent(tracker);
738
- _diffTrackersBySession.delete(trackedSessionId);
739
- removed = true;
930
+ const pendingFreezeKey = (ownerSessionId, checkpointId) => `${ownerSessionId}\0${clean(checkpointId)}`;
931
+
932
+ /**
933
+ * Seal a Lead turn: capture the worktree one last time, persist the review
934
+ * baseline and freeze the change record. Single-flight per tracker; a seal
935
+ * requested while another runs waits for it and then seals again only if a
936
+ * completion wake reopened the turn meanwhile. `reseal` (a late worker) only
937
+ * re-freezes a turn that is still sealed.
938
+ */
939
+ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
940
+ if (tracker.freezing) {
941
+ if (reseal) return tracker.freezing;
942
+ return tracker.freezing.then(() => (tracker.sealed ? undefined : sealTurn(ownerSessionId, tracker)));
740
943
  }
741
- if (_turnsBySession.delete(ownerSessionId)) removed = true;
742
- return removed;
944
+ const epoch = tracker.sealEpoch;
945
+ const agents = attributedAgents(ownerSessionId, tracker);
946
+ const key = pendingFreezeKey(ownerSessionId, tracker.checkpointId);
947
+ _pendingFreezes.set(key, (_pendingFreezes.get(key) || 0) + 1);
948
+ const run = (async () => {
949
+ if (tracker.worktreeSnapshot) {
950
+ try {
951
+ await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
952
+ } catch {
953
+ // Best-effort refresh: seal with the last captured worktree state.
954
+ }
955
+ }
956
+ if (tracker.sealEpoch !== epoch || (reseal && !tracker.sealed)) return;
957
+ tracker.sealed = true;
958
+ await persistTurnSnapshotRecord(ownerSessionId, tracker);
959
+ try {
960
+ await freezeTurnChanges(ownerSessionId, tracker, agents);
961
+ } catch {
962
+ /* the change record adds Undo history; it never fails a turn */
963
+ }
964
+ // Only a usable Git baseline can replace the exact apply_patch buffers. A
965
+ // contended worktree — and a worktree with no Git baseline at all — retains
966
+ // this session's bounded before/after content so Review can still undo its
967
+ // own files after the turn completes.
968
+ if (worktreeSnapshotUsable(tracker)) releaseDiffTrackerContent(tracker);
969
+ })().finally(() => {
970
+ if (tracker.freezing === run) tracker.freezing = null;
971
+ const count = (_pendingFreezes.get(key) || 1) - 1;
972
+ if (count > 0) _pendingFreezes.set(key, count);
973
+ else _pendingFreezes.delete(key);
974
+ });
975
+ tracker.freezing = run;
976
+ return run;
977
+ }
978
+
979
+ function resealTurn(ownerSessionId, tracker) {
980
+ if (!tracker.sealed || tracker.freezing || !clean(tracker.checkpointId) || tracker.resealQueued) return;
981
+ // On the session lock: an in-flight Undo/Reapply lands before the re-freeze
982
+ // reads the worktree. A turn start queued ahead of it runs it before opening
983
+ // (beginTurnSnapshot), so one queued re-seal covers every late worker.
984
+ tracker.resealQueued = true;
985
+ void serializeChangeMutation(ownerSessionId, () => runQueuedReseal(ownerSessionId, tracker)).catch(() => {});
986
+ }
987
+
988
+ /** Run `tracker`'s queued re-seal once, whichever lock step reaches it first. */
989
+ function runQueuedReseal(ownerSessionId, tracker) {
990
+ if (!tracker.resealQueued) return undefined;
991
+ tracker.resealQueued = false;
992
+ return sealTurn(ownerSessionId, tracker, { reseal: true });
993
+ }
994
+
995
+ /** Freeze a completed (or cancelled) Lead turn while retaining only its
996
+ * bounded review data. `expected` — the `{ generation }` beginTurnSnapshot
997
+ * resolved to — makes a delayed call a no-op once a newer turn began. */
998
+ export async function completeTurnSnapshot(sessionId, expected = null) {
999
+ const ownerSessionId = clean(sessionId);
1000
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
1001
+ if (!tracker) return false;
1002
+ const generation = Number(expected?.generation) || 0;
1003
+ if (generation && tracker.ownerGeneration !== generation) return false;
1004
+ await sealTurn(ownerSessionId, tracker);
1005
+ return true;
743
1006
  }
744
1007
 
745
1008
  /**
@@ -818,7 +1081,7 @@ function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
818
1081
  }
819
1082
 
820
1083
  /** Restore recorded paths — every one of them, or just the file the user picked. */
821
- async function revertFromRecord(worktree, ownerSessionId, file, expectedCheckpointId = '') {
1084
+ async function revertFromRecord(ownerSessionId, file, expectedCheckpointId) {
822
1085
  const resumed = await resumeRecordedSnapshot(ownerSessionId);
823
1086
  if (!resumed) return null;
824
1087
  assertCheckpointMatches(expectedCheckpointId, resumed.record.checkpointId);
@@ -827,8 +1090,8 @@ async function revertFromRecord(worktree, ownerSessionId, file, expectedCheckpoi
827
1090
  if (targets.length === 0) {
828
1091
  throw new Error('turn review revert cannot attribute this file to the session');
829
1092
  }
830
- await revertTurnWorktreePaths(resumed.snapshot, targets);
831
- return await getTurnReviewDiff(worktree, ownerSessionId);
1093
+ const paths = await revertTurnWorktreePaths(resumed.snapshot, targets);
1094
+ return { checkpointId: clean(resumed.record.checkpointId), paths };
832
1095
  }
833
1096
 
834
1097
  /** Return the authoritative Lead net diff plus attributed child reviews. */
@@ -846,7 +1109,9 @@ export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
846
1109
  const refreshOptions = options.poll === 'background' ? { visible: options.summary !== true } : { force: true };
847
1110
  try {
848
1111
  await refreshTurnWorktreeSnapshot(isolatedWorktreeSnapshot, refreshOptions);
849
- } catch {}
1112
+ } catch {
1113
+ // Best-effort refresh: the review reads the last captured state.
1114
+ }
850
1115
  }
851
1116
  const snapshot = isolatedWorktreeSnapshot;
852
1117
  // Without a usable baseline the review falls back to this session's own
@@ -895,6 +1160,39 @@ export async function getSessionReviewDiff(worktree, sessionId) {
895
1160
  if (DISABLED) return { supported: false, files: [], patch: '' };
896
1161
  const ownerSessionId = clean(sessionId);
897
1162
  if (!ownerSessionId) return { supported: true, files: [], patch: '', authoritative: true };
1163
+ const review = await baselineSessionReviewDiff(worktree, ownerSessionId);
1164
+ // The same recorded changes the turn-changes cards list: a non-Git session
1165
+ // and files outside the worktree have no baseline above, but do have these.
1166
+ // A Git session whose baseline vouches for its files has nothing to add but
1167
+ // edits outside the root, so its records are read only through the
1168
+ // mtime-keyed cache.
1169
+ const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
1170
+ const live = review.source === 'git' ? null : liveTrackedRecord(_diffTrackersBySession.get(ownerSessionId));
1171
+ return mergeRecordedSessionChanges(review, records, live);
1172
+ }
1173
+
1174
+ const LIVE_RECORD_TTL_MS = 1000;
1175
+ const _liveRecords = new WeakMap();
1176
+
1177
+ /** The running non-Git turn as a record, so its edits join the net review
1178
+ * before the turn seals. Rebuilt at most once a second. */
1179
+ function liveTrackedRecord(tracker) {
1180
+ if (!tracker || tracker.worktreeSnapshot || !tracker.worktreeRoot || !clean(tracker.checkpointId)) return null;
1181
+ const cached = _liveRecords.get(tracker);
1182
+ if (cached && Date.now() - cached.at < LIVE_RECORD_TTL_MS && cached.checkpointId === tracker.checkpointId) {
1183
+ return cached.record;
1184
+ }
1185
+ const record = {
1186
+ checkpointId: clean(tracker.checkpointId),
1187
+ kind: 'tracked',
1188
+ root: tracker.worktreeRoot,
1189
+ files: trackedRecordFiles(tracker, tracker.worktreeRoot).map(storedChangeFile),
1190
+ };
1191
+ _liveRecords.set(tracker, { at: Date.now(), checkpointId: record.checkpointId, record });
1192
+ return record;
1193
+ }
1194
+
1195
+ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
898
1196
  const tracker = _diffTrackersBySession.get(ownerSessionId);
899
1197
  const scopes = await loadSessionSnapshotRecords(ownerSessionId).catch(() => []);
900
1198
  const stored = scopes.find((scope) => scopeMatchesWorktree(scope, worktree, tracker));
@@ -905,17 +1203,24 @@ export async function getSessionReviewDiff(worktree, sessionId) {
905
1203
  if (tracker?.worktreeSnapshot && !tracker.sealed) {
906
1204
  try {
907
1205
  await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot);
908
- } catch {}
1206
+ } catch {
1207
+ // Best-effort refresh: the checkpoint diff uses the last captured state.
1208
+ }
909
1209
  }
910
1210
  const ownedPaths = new Set(stored?.toolFiles || []);
911
1211
  for (const path of sessionAttributedPaths(root, tracker)) ownedPaths.add(path);
912
1212
  if (ownedPaths.size === 0) {
1213
+ // Authoritatively empty only when the session left ownership metadata
1214
+ // behind (a stored scope or a live tracker); with neither, the retained
1215
+ // change records are the only memory of what it changed.
913
1216
  return {
914
1217
  supported: true,
1218
+ root,
915
1219
  files: [],
916
1220
  patch: '',
917
1221
  snapshotKind: 'session',
918
1222
  authoritative: true,
1223
+ source: stored || tracker ? 'empty' : 'absent',
919
1224
  };
920
1225
  }
921
1226
  const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
@@ -932,11 +1237,13 @@ export async function getSessionReviewDiff(worktree, sessionId) {
932
1237
  if (snapshot) {
933
1238
  return {
934
1239
  supported: true,
1240
+ root,
935
1241
  files: snapshot.files || [],
936
1242
  patch: snapshot.patch || '',
937
1243
  patchTruncated: snapshot.patchTruncated === true,
938
1244
  snapshotKind: 'session',
939
1245
  authoritative: true,
1246
+ source: 'git',
940
1247
  };
941
1248
  }
942
1249
  }
@@ -951,6 +1258,7 @@ export async function getSessionReviewDiff(worktree, sessionId) {
951
1258
  patchTruncated: tracker.patchTruncated === true,
952
1259
  snapshotKind: 'session-tool',
953
1260
  authoritative: true,
1261
+ source: 'tool',
954
1262
  };
955
1263
  }
956
1264
  return {
@@ -960,6 +1268,7 @@ export async function getSessionReviewDiff(worktree, sessionId) {
960
1268
  snapshotKind: 'session',
961
1269
  authoritative: false,
962
1270
  reason: 'session baseline is unavailable',
1271
+ source: 'unavailable',
963
1272
  };
964
1273
  }
965
1274
 
@@ -970,59 +1279,359 @@ function unavailableTrackedRevertReason(tracker) {
970
1279
  : 'turn review tracked file snapshot is unavailable';
971
1280
  }
972
1281
 
973
- /** Restore one reviewed file to this turn's worktree baseline, never to HEAD. */
974
- export async function revertTurnReviewFile(worktree, sessionId, file, expectedCheckpointId = '') {
975
- const ownerSessionId = clean(sessionId);
1282
+ /** Restore the latest turn — every file it can attribute (`whole`) or the one
1283
+ * the user picked — to its turn-start state. Resolves to the restored turn's
1284
+ * checkpoint and the absolute paths actually restored: a contended worktree
1285
+ * restores only the Lead's own tracked files, never its workers'. */
1286
+ async function revertLatestTurnUnlocked(worktree, ownerSessionId, file, whole, expectedCheckpointId) {
976
1287
  const tracker = _diffTrackersBySession.get(ownerSessionId);
977
1288
  if (tracker) assertCheckpointMatches(expectedCheckpointId, tracker.checkpointId);
1289
+ const restored = (paths) => ({ checkpointId: clean(tracker.checkpointId), paths });
1290
+ const recordFile = whole ? null : file;
978
1291
  if (tracker && !worktreeSnapshotUsable(tracker)) {
979
1292
  const pairs = trackedPairs(tracker);
980
1293
  if (pairs.length > 0) {
981
- const pair = pairs.find((entry) => pairMatchesFile(entry, worktree, file));
982
- if (!pair) throw new Error('turn review tracked file snapshot is unavailable');
983
- await revertTrackedPairs(tracker, worktree, [pair]);
984
- return await getTurnReviewDiff(worktree, ownerSessionId);
1294
+ let selected = pairs;
1295
+ if (!whole) {
1296
+ const pair = pairs.find((entry) => pairMatchesFile(entry, worktree, file));
1297
+ if (!pair) throw new Error('turn review tracked file snapshot is unavailable');
1298
+ selected = [pair];
1299
+ }
1300
+ return restored(await revertTrackedPairs(tracker, worktree, selected));
985
1301
  }
986
- const recorded = await revertFromRecord(worktree, ownerSessionId, file, expectedCheckpointId);
1302
+ const recorded = await revertFromRecord(ownerSessionId, recordFile, expectedCheckpointId);
987
1303
  if (recorded) return recorded;
988
1304
  throw new Error(unavailableTrackedRevertReason(tracker));
989
1305
  }
990
1306
  if (tracker?.worktreeSnapshot) {
991
- await revertTurnWorktreeFile(tracker.worktreeSnapshot, file);
992
- return await getTurnReviewDiff(worktree, ownerSessionId);
1307
+ return restored(
1308
+ whole
1309
+ ? await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot)
1310
+ : await revertTurnWorktreeFile(tracker.worktreeSnapshot, file)
1311
+ );
993
1312
  }
994
- const recorded = await revertFromRecord(worktree, ownerSessionId, file, expectedCheckpointId);
1313
+ const recorded = await revertFromRecord(ownerSessionId, recordFile, expectedCheckpointId);
995
1314
  if (recorded) return recorded;
996
1315
  throw new Error('turn worktree snapshot is unavailable');
997
1316
  }
998
1317
 
1318
+ /** Record a composer restore in the turn's change record: a record file whose
1319
+ * every side (a rename's old path too) was restored is back at its
1320
+ * turn-start bytes, i.e. `reverted`; every other file keeps its state. */
1321
+ async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
1322
+ if (!ownerSessionId || !checkpointId) return;
1323
+ const record = await loadTurnChangeRecord(ownerSessionId, checkpointId);
1324
+ if (!record) return;
1325
+ const restored = new Set(paths.map((path) => changePathKey(relative(record.root, path))));
1326
+ const keys = new Set(
1327
+ (record.files || [])
1328
+ .filter((entry) => [entry.path, entry.oldPath].filter(Boolean).every((side) => restored.has(changePathKey(side))))
1329
+ .map((entry) => changePathKey(entry.path))
1330
+ );
1331
+ if (keys.size === 0) return;
1332
+ try {
1333
+ await writeTurnChangeState(ownerSessionId, checkpointId, keys, 'reverted');
1334
+ } catch (error) {
1335
+ await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
1336
+ throw new Error(
1337
+ `the turn's files were restored, but its change record could not be updated (${error.message}); Undo/Reapply of those files is disabled`,
1338
+ { cause: error }
1339
+ );
1340
+ }
1341
+ }
1342
+
1343
+ /** Composer Undo of the latest turn, on the per-session change lock shared
1344
+ * with historical Undo/Reapply, recorded in the turn's change record so the
1345
+ * historical view offers Reapply rather than a second Undo. */
1346
+ function revertLatestTurn(worktree, sessionId, file, whole, expectedCheckpointId) {
1347
+ const ownerSessionId = clean(sessionId);
1348
+ return serializeChangeMutation(ownerSessionId, async () => {
1349
+ // A seal still freezing would record the pre-restore disk after this.
1350
+ const freezing = _diffTrackersBySession.get(ownerSessionId)?.freezing;
1351
+ if (freezing) await freezing.catch(() => {});
1352
+ const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file, whole, expectedCheckpointId);
1353
+ await recordComposerRevert(ownerSessionId, restored);
1354
+ return await getTurnReviewDiff(worktree, ownerSessionId);
1355
+ });
1356
+ }
1357
+
1358
+ /** Restore one reviewed file to this turn's worktree baseline, never to HEAD. */
1359
+ export function revertTurnReviewFile(worktree, sessionId, file, expectedCheckpointId = '') {
1360
+ return revertLatestTurn(worktree, sessionId, file, false, expectedCheckpointId);
1361
+ }
1362
+
999
1363
  /** Restore every file in the reviewed turn to its turn-start state. */
1000
- export async function revertTurnReview(worktree, sessionId, expectedCheckpointId = '') {
1364
+ export function revertTurnReview(worktree, sessionId, expectedCheckpointId = '') {
1365
+ return revertLatestTurn(worktree, sessionId, null, true, expectedCheckpointId);
1366
+ }
1367
+
1368
+ // ── Historical turn changes (per-turn Undo/Reapply) ─────────────────────────
1369
+
1370
+ /** A Lead turn of this session is running or still sealing. Only a tracker
1371
+ * beginTurnSnapshot opened counts: an orphan tool tracker is no turn. */
1372
+ function sessionTurnBusy(ownerSessionId) {
1373
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
1374
+ if (tracker && tracker.ownerGeneration > 0 && (!tracker.sealed || tracker.freezing)) return true;
1375
+ for (const key of _pendingFreezes.keys()) {
1376
+ if (key.startsWith(`${ownerSessionId}\0`)) return true;
1377
+ }
1378
+ return false;
1379
+ }
1380
+
1381
+ function turnChangesPending(ownerSessionId, checkpointId) {
1382
+ if (_pendingFreezes.has(pendingFreezeKey(ownerSessionId, checkpointId))) return true;
1383
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
1384
+ return Boolean(tracker && tracker.ownerGeneration > 0 && !tracker.sealed && tracker.checkpointId === checkpointId);
1385
+ }
1386
+
1387
+ function requiredCheckpointId(value) {
1388
+ const checkpointId = clean(value);
1389
+ if (!checkpointId) throw new TypeError('checkpointId is required');
1390
+ return checkpointId;
1391
+ }
1392
+
1393
+ /** One turn's frozen changes by exact checkpoint id (TurnChangesReply). An
1394
+ * unknown checkpoint is `missing`; it never resolves to the latest turn. */
1395
+ export async function getTurnChanges(sessionId, options = {}) {
1001
1396
  const ownerSessionId = clean(sessionId);
1397
+ const checkpointId = requiredCheckpointId(options?.checkpointId);
1398
+ let record = ownerSessionId
1399
+ ? withUnknownChangeStates(ownerSessionId, await loadTurnChangeRecord(ownerSessionId, checkpointId))
1400
+ : null;
1401
+ // An old record's outside-root file kept no counts: measure it so the card
1402
+ // agrees with the session review and the island.
1403
+ if (record?.files?.length) {
1404
+ record = {
1405
+ ...record,
1406
+ files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root))),
1407
+ };
1408
+ }
1409
+ let status = 'missing';
1410
+ if (ownerSessionId && turnChangesPending(ownerSessionId, checkpointId)) status = 'pending';
1411
+ else if (record) status = record.files?.length || record.outside?.length ? 'ready' : 'none';
1412
+ return turnChangesReply({
1413
+ status,
1414
+ checkpointId,
1415
+ record,
1416
+ patch: options?.patch === true,
1417
+ file: clean(options?.file) || null,
1418
+ });
1419
+ }
1420
+
1421
+ function serializeChangeMutation(ownerSessionId, task) {
1422
+ const previous = _changeMutations.get(ownerSessionId) || Promise.resolve();
1423
+ const next = previous.then(task, task);
1424
+ const settled = next.then(
1425
+ () => undefined,
1426
+ () => undefined
1427
+ );
1428
+ _changeMutations.set(ownerSessionId, settled);
1429
+ settled.then(() => {
1430
+ if (_changeMutations.get(ownerSessionId) === settled) _changeMutations.delete(ownerSessionId);
1431
+ });
1432
+ return next;
1433
+ }
1434
+
1435
+ const fileState = (file) => (file.state === 'reverted' ? 'reverted' : 'applied');
1436
+
1437
+ /** Persist `state` for the record files under `keys`, bumping the revision
1438
+ * when anything changed: the one state write of composer and historical Undo. */
1439
+ function writeTurnChangeState(ownerSessionId, checkpointId, keys, state) {
1440
+ return updateTurnChangeRecord(ownerSessionId, checkpointId, (current) => {
1441
+ let changed = false;
1442
+ const files = (current.files || []).map((entry) => {
1443
+ if (!keys.has(changePathKey(entry.path)) || fileState(entry) === state) return entry;
1444
+ changed = true;
1445
+ return { ...entry, state };
1446
+ });
1447
+ return changed ? files : null;
1448
+ });
1449
+ }
1450
+
1451
+ const markUnknownStates = (files, keys) =>
1452
+ (files || []).map((entry) => (keys.has(changePathKey(entry.path)) ? unknownStateChangeFile(entry) : entry));
1453
+
1454
+ /** A state write failed after the worktree changed and could not be undone:
1455
+ * mark those files unknown in the record, or — when the store refuses that
1456
+ * write too — for the rest of this runtime, so no reply reads them as
1457
+ * cleanly applied or reverted. */
1458
+ async function markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys) {
1459
+ try {
1460
+ await updateTurnChangeRecord(ownerSessionId, checkpointId, (current) => markUnknownStates(current.files, keys));
1461
+ return;
1462
+ } catch {
1463
+ // The store refused the write: fall back to the in-memory marker below.
1464
+ }
1465
+ const key = pendingFreezeKey(ownerSessionId, checkpointId);
1466
+ _unknownChangeStates.set(key, new Set([...(_unknownChangeStates.get(key) || []), ...keys]));
1467
+ }
1468
+
1469
+ function withUnknownChangeStates(ownerSessionId, record) {
1470
+ const keys = record && _unknownChangeStates.get(pendingFreezeKey(ownerSessionId, record.checkpointId));
1471
+ return keys ? { ...record, files: markUnknownStates(record.files, keys) } : record;
1472
+ }
1473
+
1474
+ /** The exact-buffer review tracker (non-Git or contended worktree) an
1475
+ * Undo/Reapply must fold into, with the pre-mutation bytes of every moved
1476
+ * file under the tracker's root. Null when the review has a usable Git
1477
+ * baseline (it re-captures instead) or nothing to fold. */
1478
+ async function trackedReviewFold(ownerSessionId, root, targets, reverse) {
1002
1479
  const tracker = _diffTrackersBySession.get(ownerSessionId);
1003
- if (tracker) assertCheckpointMatches(expectedCheckpointId, tracker.checkpointId);
1004
- if (tracker && !worktreeSnapshotUsable(tracker)) {
1005
- const pairs = trackedPairs(tracker);
1006
- if (pairs.length > 0) {
1007
- await revertTrackedPairs(tracker, worktree, pairs);
1008
- return await getTurnReviewDiff(worktree, ownerSessionId);
1009
- }
1010
- const recorded = await revertFromRecord(worktree, ownerSessionId, null, expectedCheckpointId);
1011
- if (recorded) return recorded;
1012
- throw new Error(unavailableTrackedRevertReason(tracker));
1480
+ if (!tracker?.valid || worktreeSnapshotUsable(tracker) || !tracker.worktreeRoot) return null;
1481
+ const inside = (path) => {
1482
+ const rel = relative(tracker.worktreeRoot, path);
1483
+ return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
1484
+ };
1485
+ const moves = [];
1486
+ for (const entry of targets) {
1487
+ const path = resolve(root, entry.path);
1488
+ const oldPath = entry.oldPath ? resolve(root, entry.oldPath) : path;
1489
+ const move = reverse ? { source: path, destination: oldPath } : { source: oldPath, destination: path };
1490
+ if (inside(move.source) && inside(move.destination)) moves.push(move);
1013
1491
  }
1014
- if (tracker?.worktreeSnapshot) {
1015
- await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot);
1016
- return await getTurnReviewDiff(worktree, ownerSessionId);
1492
+ if (moves.length === 0) return null;
1493
+ try {
1494
+ for (const move of moves) move.before = await currentTrackedContent(move.source);
1495
+ } catch {
1496
+ return { tracker, moves, unreadable: true };
1017
1497
  }
1018
- const recorded = await revertFromRecord(worktree, ownerSessionId, null, expectedCheckpointId);
1019
- if (recorded) return recorded;
1020
- throw new Error('turn worktree snapshot is unavailable');
1498
+ return { tracker, moves };
1499
+ }
1500
+
1501
+ /** One moved file as a tracked change: its pre-mutation bytes and what the
1502
+ * mutation left at its destination. */
1503
+ async function movedFileChange(root, move) {
1504
+ const change = {
1505
+ path: move.source,
1506
+ displayPath: trackedRelativePath(root, { path: move.source }),
1507
+ before: move.before,
1508
+ after: await currentTrackedContent(move.destination),
1509
+ };
1510
+ if (move.destination === move.source) return change;
1511
+ return {
1512
+ ...change,
1513
+ newPath: move.destination,
1514
+ newDisplayPath: trackedRelativePath(root, { path: move.destination }),
1515
+ };
1516
+ }
1517
+
1518
+ /** Fold an applied Undo/Reapply into the exact tracker. A file the tracker
1519
+ * cannot read back exactly drops the tracked review rather than leaving it
1520
+ * describing the old disk. */
1521
+ async function foldReviewMutation({ tracker, moves, unreadable }) {
1522
+ try {
1523
+ if (unreadable) throw new Error('unreadable');
1524
+ const changes = [];
1525
+ for (const move of moves) changes.push(await movedFileChange(tracker.worktreeRoot, move));
1526
+ foldTrackedChanges(tracker, changes, null);
1527
+ if (!tracker.unifiedDiff) releaseDiffTrackerContent(tracker);
1528
+ } catch {
1529
+ tracker.valid = false;
1530
+ releaseDiffTrackerContent(tracker);
1531
+ refreshUnifiedDiff(tracker);
1532
+ }
1533
+ }
1534
+
1535
+ /** Bring the latest-turn review (composer bar, session Diff) in line with
1536
+ * the disk an Undo/Reapply just changed: signal the moved paths, fold the
1537
+ * mutation into the exact tracker, re-capture the Git snapshot (sealed ones
1538
+ * too) and persist its attribution. Best-effort: the mutation itself
1539
+ * already landed. */
1540
+ async function refreshLatestTurnReview(ownerSessionId, root, targets, fold) {
1541
+ noteTurnWorktreeChange(
1542
+ targets
1543
+ .flatMap((entry) => [entry.path, entry.oldPath])
1544
+ .filter(Boolean)
1545
+ .map((path) => resolve(root, path))
1546
+ );
1547
+ if (fold?.tracker.valid) await foldReviewMutation(fold);
1548
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
1549
+ if (!tracker?.worktreeSnapshot) return;
1550
+ try {
1551
+ await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
1552
+ } catch {
1553
+ // Best-effort refresh: persist the last captured state.
1554
+ }
1555
+ if (tracker.sealed) await persistTurnSnapshotRecord(ownerSessionId, tracker);
1556
+ }
1557
+
1558
+ /** Undo (`reverse`) or Reapply one file or the whole turn, all-or-nothing. */
1559
+ async function mutateTurnChanges(sessionId, checkpointIdValue, fileValue, revision, reverse) {
1560
+ const ownerSessionId = clean(sessionId);
1561
+ if (!ownerSessionId) throw new Error('turn changes are unavailable without a session');
1562
+ const checkpointId = requiredCheckpointId(checkpointIdValue);
1563
+ if (!Number.isSafeInteger(revision)) throw new TypeError('revision must be an integer');
1564
+ const file = fileValue === null || fileValue === undefined ? null : clean(fileValue);
1565
+ const verb = reverse ? 'undone' : 'reapplied';
1566
+ return await serializeChangeMutation(ownerSessionId, async () => {
1567
+ if (sessionTurnBusy(ownerSessionId)) {
1568
+ throw new Error('turn changes cannot change while this session is running a turn');
1569
+ }
1570
+ const record = withUnknownChangeStates(ownerSessionId, await loadTurnChangeRecord(ownerSessionId, checkpointId));
1571
+ if (!record) throw new Error('turn changes are unavailable: no record for this turn');
1572
+ if (Number(record.revision) !== revision) throw new Error('turn changes changed; refresh');
1573
+ const from = reverse ? 'applied' : 'reverted';
1574
+ let targets;
1575
+ if (file) {
1576
+ const match = findChangeFile(record, file);
1577
+ if (!match) throw new Error(`${file} is not part of this turn's changes`);
1578
+ if (fileState(match) !== from) throw new Error(`${match.path} is already ${verb}`);
1579
+ targets = [match];
1580
+ } else {
1581
+ targets = (record.files || []).filter((entry) => fileState(entry) === from);
1582
+ if (targets.length === 0) throw new Error(`this turn has no changes to be ${verb}`);
1583
+ }
1584
+ const blocked = targets.find((entry) => entry.revertable !== true);
1585
+ if (blocked) {
1586
+ throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
1587
+ }
1588
+ const fold = await trackedReviewFold(ownerSessionId, record.root, targets, reverse);
1589
+ const root = await applyTurnChangeFiles(record, targets, { reverse });
1590
+ // State lands only after git applied every target; a state that cannot
1591
+ // land takes the worktree change back with it.
1592
+ const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
1593
+ let updated;
1594
+ try {
1595
+ updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
1596
+ } catch (error) {
1597
+ try {
1598
+ await applyTurnChangeFiles(record, targets, { reverse: !reverse });
1599
+ } catch (rollbackError) {
1600
+ await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
1601
+ await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
1602
+ throw new Error(
1603
+ `turn changes were ${verb} but could not be recorded (${error.message}), and restoring the worktree failed (${rollbackError.message}); Undo/Reapply of those files is disabled`,
1604
+ { cause: error }
1605
+ );
1606
+ }
1607
+ throw error;
1608
+ }
1609
+ await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
1610
+ return turnChangesReply({
1611
+ status: updated ? 'ready' : 'missing',
1612
+ checkpointId,
1613
+ record: withUnknownChangeStates(ownerSessionId, updated),
1614
+ });
1615
+ });
1616
+ }
1617
+
1618
+ /** Reverse-apply one recorded file (`file`) or every applied file (`null`). */
1619
+ export function revertTurnChanges(sessionId, checkpointId, file, revision) {
1620
+ return mutateTurnChanges(sessionId, checkpointId, file, revision, true);
1621
+ }
1622
+
1623
+ /** Forward-apply one reverted file (`file`) or every reverted file (`null`). */
1624
+ export function reapplyTurnChanges(sessionId, checkpointId, file, revision) {
1625
+ return mutateTurnChanges(sessionId, checkpointId, file, revision, false);
1021
1626
  }
1022
1627
 
1023
1628
  export function _resetTurnSnapshotForTest() {
1024
1629
  _turnsBySession.clear();
1025
1630
  _diffTrackersBySession.clear();
1631
+ _pendingFreezes.clear();
1632
+ _changeMutations.clear();
1633
+ _unknownChangeStates.clear();
1634
+ _turnOpenedHookForTest = null;
1026
1635
  _agentTurnSeq = 0;
1027
1636
  _resetTurnWorktreeSnapshotsForTest();
1028
1637
  }