mixdog 1.2.2 → 1.2.3

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 (415) hide show
  1. package/README.md +18 -0
  2. package/package.json +2 -1
  3. package/scripts/provider-pingpong-live.mjs +509 -0
  4. package/src/agents/advisor/AGENT.md +1 -1
  5. package/src/agents/advisor/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +5 -0
  7. package/src/defaults/skills/setup/SKILL.md +6 -1
  8. package/src/defaults/skills/setup/references/actions.md +44 -22
  9. package/src/defaults/skills/setup/references/surfaces.md +10 -8
  10. package/src/headless-exec.mjs +15 -8
  11. package/src/lib/keychain-cjs.cjs +7 -4
  12. package/src/output-styles/common.md +11 -10
  13. package/src/rules/agent/AGENT.md +5 -5
  14. package/src/rules/lead/LEAD.md +5 -5
  15. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +92 -61
  16. package/src/runtime/agent/orchestrator/cache-mtime.mjs +10 -1
  17. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +7 -0
  18. package/src/runtime/agent/orchestrator/config.mjs +5 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -0
  20. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +26 -7
  21. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +112 -7
  22. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +48 -20
  23. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +2 -0
  24. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +24 -17
  25. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +7 -6
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +29 -10
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-blocks.mjs +20 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-delta.mjs +15 -2
  30. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-start.mjs +11 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/message-events.mjs +3 -0
  32. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events.mjs +12 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-outcome.mjs +1 -0
  34. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-state.mjs +14 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +9 -0
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -0
  38. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +58 -7
  39. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +29 -10
  40. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +16 -3
  41. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +10 -61
  42. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +12 -6
  43. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +114 -72
  45. package/src/runtime/agent/orchestrator/providers/compat-request-policy.mjs +78 -2
  46. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +37 -5
  47. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +1 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-messages.mjs +43 -2
  50. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +3 -0
  51. package/src/runtime/agent/orchestrator/providers/cursor.mjs +9 -2
  52. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +64 -1
  53. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +70 -6
  54. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +34 -7
  55. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +32 -6
  56. package/src/runtime/agent/orchestrator/providers/deepseek.mjs +67 -0
  57. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +11 -4
  59. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +25 -8
  60. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +87 -5
  61. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +41 -5
  62. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +13 -13
  63. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +355 -37
  64. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -2
  65. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +93 -47
  66. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +55 -10
  67. package/src/runtime/agent/orchestrator/providers/gemini.mjs +26 -8
  68. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/providers/lib/action-input-contract.mjs +60 -0
  71. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +1 -0
  72. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +246 -42
  73. package/src/runtime/agent/orchestrator/providers/lib/anthropic-server-tool-usage.mjs +18 -0
  74. package/src/runtime/agent/orchestrator/providers/lib/anthropic-system-blocks.mjs +27 -6
  75. package/src/runtime/agent/orchestrator/providers/lib/claude-bridge-schema.mjs +388 -0
  76. package/src/runtime/agent/orchestrator/providers/lib/inclusive-usage.mjs +54 -0
  77. package/src/runtime/agent/orchestrator/providers/lib/origin-bound-fetch.mjs +16 -0
  78. package/src/runtime/agent/orchestrator/providers/lib/responses-terminal-fields.mjs +11 -0
  79. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +8 -0
  80. package/src/runtime/agent/orchestrator/providers/lib/wire-pairing.mjs +48 -22
  81. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +119 -18
  82. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +13 -15
  83. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +1 -1
  84. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +1 -0
  85. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +33 -1
  86. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +2 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -0
  88. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +121 -61
  89. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +35 -14
  90. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +5 -2
  91. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +37 -7
  92. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/tool-call-acc.mjs +2 -9
  93. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +47 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +47 -25
  96. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +5 -1
  97. package/src/runtime/agent/orchestrator/providers/openai-compat-reasoning-field.mjs +151 -0
  98. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +42 -24
  99. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +63 -25
  100. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2901 -0
  101. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-parity.fixtures.mjs +172 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +53 -5
  103. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -1
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +2 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +103 -26
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +23 -4
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +11 -6
  108. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  109. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +15 -23
  110. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +23 -10
  111. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +25 -4
  112. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +10 -0
  113. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-items.mjs +18 -7
  114. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +4 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +5 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/text-relay.mjs +9 -1
  117. package/src/runtime/agent/orchestrator/providers/openai-model-capabilities.mjs +80 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +3 -1
  119. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -0
  120. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +7 -1
  121. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +71 -24
  122. package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +323 -0
  123. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +44 -16
  124. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +7 -16
  125. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/refusals.mjs +60 -0
  126. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/tool-calls.mjs +51 -35
  127. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state.mjs +30 -0
  128. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +12 -0
  129. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/terminal-frames.mjs +25 -6
  130. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +11 -3
  131. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +170 -0
  132. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +75 -70
  133. package/src/runtime/agent/orchestrator/providers/openrouter.mjs +75 -0
  134. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +147 -8
  135. package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -1
  136. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +5 -0
  137. package/src/runtime/agent/orchestrator/providers/transport-scope.d.mts +6 -0
  138. package/src/runtime/agent/orchestrator/providers/transport-scope.mjs +28 -0
  139. package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +157 -0
  140. package/src/runtime/agent/orchestrator/providers/wire-route.mjs +512 -0
  141. package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +1131 -0
  142. package/src/runtime/agent/orchestrator/providers/xai-tool-search.fixtures.mjs +183 -0
  143. package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +100 -0
  144. package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +21 -0
  145. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -0
  146. package/src/runtime/agent/orchestrator/runtime-core/provider-request-snapshot.mjs +7 -3
  147. package/src/runtime/agent/orchestrator/runtime-core/session-title.mjs +21 -1
  148. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +14 -3
  149. package/src/runtime/agent/orchestrator/runtime-core/tool-bridge.mjs +526 -0
  150. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  151. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +10 -7
  152. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +176 -71
  153. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +62 -10
  154. package/src/runtime/agent/orchestrator/runtime-core/tool-exposure.mjs +96 -0
  155. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  156. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -2
  157. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +14 -93
  158. package/src/runtime/agent/orchestrator/session/compact/summary-anchor.mjs +5 -0
  159. package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +120 -0
  160. package/src/runtime/agent/orchestrator/session/context-tool-schema.mjs +17 -2
  161. package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -4
  162. package/src/runtime/agent/orchestrator/session/eager-dispatch/admission.mjs +2 -1
  163. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +6 -1
  164. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +91 -1
  165. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +1 -1
  166. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +56 -0
  167. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +18 -8
  168. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -0
  169. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +21 -10
  170. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +132 -0
  171. package/src/runtime/agent/orchestrator/session/loop/request-boundary.mjs +30 -0
  172. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +63 -20
  173. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +2 -1
  174. package/src/runtime/agent/orchestrator/session/loop/tool-call-resolution.mjs +95 -0
  175. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +30 -3
  176. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +15 -4
  177. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
  178. package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +150 -0
  179. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +4 -1
  180. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -1
  181. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +34 -12
  182. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +16 -0
  183. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +57 -25
  184. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +6 -0
  185. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +4 -0
  186. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  187. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +208 -14
  188. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +15 -3
  189. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +25 -3
  190. package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +82 -0
  191. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +19 -2
  192. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
  193. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  194. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -1
  195. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +6 -0
  196. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +13 -0
  197. package/src/runtime/agent/orchestrator/session/side-question-store.mjs +96 -0
  198. package/src/runtime/agent/orchestrator/session/side-question.mjs +192 -0
  199. package/src/runtime/agent/orchestrator/session/store/lifecycle-barriers.mjs +17 -3
  200. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +23 -0
  201. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +58 -2
  202. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +41 -28
  203. package/src/runtime/agent/orchestrator/session/store/session-observers.mjs +4 -2
  204. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +12 -1
  205. package/src/runtime/agent/orchestrator/session/store/write-admission.mjs +3 -1
  206. package/src/runtime/agent/orchestrator/session/store-summary-archived-agent.mjs +34 -15
  207. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +4 -0
  208. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +8 -0
  209. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +10 -1
  210. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  211. package/src/runtime/agent/orchestrator/session/store.mjs +19 -3
  212. package/src/runtime/agent/orchestrator/session/tool-batch/execute-call.mjs +1 -0
  213. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +1 -1
  214. package/src/runtime/agent/orchestrator/session/tool-batch/record-outcome.mjs +1 -1
  215. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +7 -1
  216. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -0
  217. package/src/runtime/agent/orchestrator/session/tool-schema-rejection.mjs +77 -0
  218. package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +8 -2
  219. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +3 -3
  220. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  221. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +14 -10
  222. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +49 -34
  223. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +23 -1
  224. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +1 -1
  225. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1 -1
  226. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +73 -39
  227. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +18 -1
  228. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -4
  229. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +88 -12
  230. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +2 -0
  231. package/src/runtime/computer-bridge/action-schema.mjs +44 -21
  232. package/src/runtime/github/tool.mjs +92 -2
  233. package/src/runtime/memory/index.mjs +1 -0
  234. package/src/runtime/memory/lib/http-router/ingest-routes.mjs +22 -1
  235. package/src/runtime/memory/lib/ingest/transcript-offsets.mjs +2 -0
  236. package/src/runtime/memory/lib/pg/adapter.mjs +98 -1
  237. package/src/runtime/memory/lib/pg/process.mjs +31 -26
  238. package/src/runtime/memory/lib/pg/run-child.mjs +49 -0
  239. package/src/runtime/memory/lib/pg/supervisor.mjs +108 -3
  240. package/src/runtime/memory/lib/runtime-fetcher.mjs +12 -13
  241. package/src/runtime/memory/lib/transcript-ingest.mjs +14 -0
  242. package/src/runtime/office/README.md +1 -1
  243. package/src/runtime/office/authoring/html-document-author.mjs +2 -0
  244. package/src/runtime/office/authoring/pptx-contact-sheet.mjs +2 -0
  245. package/src/runtime/office/authoring/pptx-html-measure.mjs +2 -0
  246. package/src/runtime/office/authoring/pptx-html-runner.mjs +2 -0
  247. package/src/runtime/office/authoring/xlsx-html-runner.mjs +2 -0
  248. package/src/runtime/office/core/office-actions-read.mjs +3 -0
  249. package/src/runtime/office/core/office-render-preview.mjs +2 -0
  250. package/src/runtime/office/core/office-session-dispatch.mjs +7 -0
  251. package/src/runtime/office/core/office-sessionless-actions.mjs +8 -0
  252. package/src/runtime/office/core/office-sessions.mjs +5 -0
  253. package/src/runtime/office/core/office-transactions.mjs +3 -0
  254. package/src/runtime/office/core/office-write-ledger.mjs +98 -0
  255. package/src/runtime/office/design/library/templates/mixdog-executive.mixdog-edit.pptx +0 -0
  256. package/src/runtime/office/index.mjs +30 -0
  257. package/src/runtime/office/pdf/pdf-edit-document.mjs +2 -0
  258. package/src/runtime/office/pdf/pdf-render.mjs +23 -7
  259. package/src/runtime/office/pdf/pdf-security.mjs +2 -0
  260. package/src/runtime/office/portable/portable-opc.mjs +4 -1
  261. package/src/runtime/office/quality/visual-diff.mjs +2 -0
  262. package/src/runtime/shared/bounded-file-read.mjs +13 -0
  263. package/src/runtime/shared/bounded-line-diff.mjs +182 -0
  264. package/src/runtime/shared/buffered-appender.mjs +18 -0
  265. package/src/runtime/shared/config.mjs +78 -18
  266. package/src/runtime/shared/err-text.mjs +6 -0
  267. package/src/runtime/shared/file-permissions.d.mts +6 -1
  268. package/src/runtime/shared/file-permissions.mjs +54 -6
  269. package/src/runtime/shared/llm/anthropic-effort.mjs +6 -0
  270. package/src/runtime/shared/llm/usage-accounting.mjs +5 -2
  271. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +3 -0
  272. package/src/runtime/shared/loopback-listener.mjs +9 -0
  273. package/src/runtime/shared/provider-api-key.mjs +2 -0
  274. package/src/runtime/shared/review-diff.mjs +52 -0
  275. package/src/runtime/shared/session-sidebar-host.d.mts +4 -0
  276. package/src/runtime/shared/session-sidebar-host.mjs +38 -0
  277. package/src/runtime/shared/slow-span.mjs +114 -0
  278. package/src/runtime/shared/statusline/statusline-segments.mjs +28 -4
  279. package/src/runtime/shared/tool-card-model/card-header.mjs +6 -1
  280. package/src/runtime/shared/tool-card-model/generic-detail.mjs +39 -5
  281. package/src/runtime/shared/tool-card-model.mjs +5 -2
  282. package/src/runtime/shared/tool-output-limit.mjs +4 -0
  283. package/src/runtime/shared/tool-primitives.mjs +13 -0
  284. package/src/runtime/shared/tool-result-summary.mjs +8 -1
  285. package/src/runtime/shared/tool-surface.mjs +18 -3
  286. package/src/runtime/shared/tool-work-units.mjs +40 -4
  287. package/src/runtime/shared/transcript-writer/purge.mjs +74 -0
  288. package/src/runtime/shared/transcript-writer.mjs +10 -2
  289. package/src/runtime/shared/turn-changes.mjs +268 -100
  290. package/src/runtime/shared/turn-review-reply.mjs +8 -39
  291. package/src/runtime/shared/turn-snapshot-store.mjs +19 -108
  292. package/src/runtime/shared/turn-snapshot.mjs +857 -683
  293. package/src/runtime/shared/turn-worktree-snapshot.mjs +183 -255
  294. package/src/runtime/shared/user-data-guard.mjs +262 -49
  295. package/src/runtime/shared/worktrees/carry.mjs +531 -0
  296. package/src/runtime/shared/worktrees/contract.mjs +34 -0
  297. package/src/runtime/shared/worktrees/file-recovery.mjs +68 -0
  298. package/src/runtime/shared/worktrees/git-head.mjs +205 -0
  299. package/src/runtime/shared/worktrees/git.mjs +8 -3
  300. package/src/runtime/shared/worktrees/maintenance.mjs +1 -1
  301. package/src/runtime/shared/worktrees/manager.mjs +156 -8
  302. package/src/runtime/shared/worktrees/merge.mjs +31 -21
  303. package/src/runtime/shared/worktrees/paths.d.mts +6 -0
  304. package/src/runtime/terminal-bridge/client.mjs +44 -11
  305. package/src/runtime/terminal-bridge/tool-defs.mjs +13 -6
  306. package/src/runtime/tidy/apply.mjs +47 -2
  307. package/src/runtime/tidy/run-engines.mjs +11 -5
  308. package/src/session-runtime/agent-tool-routing.mjs +19 -13
  309. package/src/session-runtime/boot/apis.mjs +13 -5
  310. package/src/session-runtime/boot/facade.mjs +1 -0
  311. package/src/session-runtime/boot/infrastructure.mjs +6 -1
  312. package/src/session-runtime/boot/session.mjs +0 -1
  313. package/src/session-runtime/boot/tools.mjs +4 -1
  314. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +12 -5
  315. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +65 -0
  316. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -1
  317. package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +11 -4
  318. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +115 -18
  319. package/src/session-runtime/internal-tool-executor.mjs +6 -2
  320. package/src/session-runtime/lifecycle/cold-heir.mjs +188 -0
  321. package/src/session-runtime/lifecycle/inheritance.mjs +71 -27
  322. package/src/session-runtime/lifecycle/session-catalog.mjs +12 -3
  323. package/src/session-runtime/lifecycle/session-switch/resume.mjs +6 -0
  324. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +29 -6
  325. package/src/session-runtime/lifecycle/teardown.mjs +72 -7
  326. package/src/session-runtime/lifecycle-api.mjs +1 -0
  327. package/src/session-runtime/mcp-glue/connect.mjs +17 -5
  328. package/src/session-runtime/model-route/set-route.mjs +2 -2
  329. package/src/session-runtime/native-web-search-usage.mjs +51 -0
  330. package/src/session-runtime/native-web-search.mjs +10 -1
  331. package/src/session-runtime/new-session-config.mjs +4 -2
  332. package/src/session-runtime/provider-auth/login.mjs +4 -0
  333. package/src/session-runtime/resource-scope-decorate.mjs +10 -3
  334. package/src/session-runtime/runtime-review-api.mjs +8 -2
  335. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +81 -20
  336. package/src/session-runtime/services/agent-tool/lead-worker-index/forgotten-leads.mjs +29 -0
  337. package/src/session-runtime/services/agent-tool/lead-worker-index/index-file.mjs +1 -1
  338. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +73 -7
  339. package/src/session-runtime/services/agent-tool/tag-registry.mjs +15 -1
  340. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +10 -1
  341. package/src/session-runtime/services/agent-tool/worker-index.mjs +46 -7
  342. package/src/session-runtime/services/agent-tool.mjs +3 -0
  343. package/src/session-runtime/services/channel-admin.mjs +19 -3
  344. package/src/session-runtime/services/memory-runtime-proxy/calls.mjs +4 -0
  345. package/src/session-runtime/services/memory-runtime-proxy.mjs +1 -0
  346. package/src/session-runtime/services/provider-admin.mjs +81 -15
  347. package/src/session-runtime/session-transcript-purge.mjs +32 -0
  348. package/src/session-runtime/session-turn-api.mjs +14 -5
  349. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -0
  350. package/src/session-runtime/setup-tool/executor.mjs +36 -6
  351. package/src/session-runtime/setup-tool/extended-actions.mjs +86 -6
  352. package/src/session-runtime/setup-tool/settings-contract.mjs +13 -3
  353. package/src/session-runtime/setup-tool/tool-defs.mjs +13 -4
  354. package/src/session-runtime/skills-api.mjs +3 -5
  355. package/src/session-runtime/tool-surface.mjs +5 -1
  356. package/src/session-runtime/turn/compaction-gate.mjs +25 -0
  357. package/src/session-runtime/turn/session-ops.mjs +7 -5
  358. package/src/session-runtime/turn/side-question-ops.mjs +22 -0
  359. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +5 -1
  360. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +8 -3
  361. package/src/session-runtime/turn/turn-run/turn-settle.mjs +10 -0
  362. package/src/session-runtime/turn/turn-run/turn-timing.mjs +4 -3
  363. package/src/session-runtime/turn/turn-run.mjs +25 -9
  364. package/src/session-runtime/usage-stats-api.mjs +43 -11
  365. package/src/session-runtime/workspace/project-branches.mjs +7 -5
  366. package/src/session-runtime/workspace/transition-steps.mjs +129 -31
  367. package/src/session-runtime/workspace/transitions.mjs +190 -15
  368. package/src/standalone/daemon-agent-control.mjs +9 -1
  369. package/src/standalone/daemon-local-session-bridge.mjs +14 -1
  370. package/src/standalone/daemon-owner.mjs +17 -4
  371. package/src/standalone/daemon-pg-stop.mjs +51 -0
  372. package/src/standalone/daemon-stop-budget.d.mts +2 -0
  373. package/src/standalone/daemon-stop-budget.mjs +18 -0
  374. package/src/standalone/daemon-stored-session-views.mjs +2 -0
  375. package/src/standalone/daemon-telemetry.mjs +19 -0
  376. package/src/standalone/daemon.mjs +61 -1
  377. package/src/standalone/desktop-service-registry.mjs +5 -0
  378. package/src/standalone/local-session-runtime.mjs +70 -19
  379. package/src/standalone/session-client.mjs +42 -0
  380. package/src/standalone/session-protocol.mjs +7 -0
  381. package/src/standalone/session-proxy/projection.mjs +41 -4
  382. package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +15 -1
  383. package/src/standalone/session-runtime-inline-host.mjs +1 -0
  384. package/src/standalone/session-service/session-calls/session-action.mjs +18 -3
  385. package/src/standalone/session-service-api.mjs +6 -1
  386. package/src/standalone/session-state-patch.d.mts +2 -1
  387. package/src/standalone/session-state-patch.mjs +41 -1
  388. package/src/tui/App.jsx +25 -0
  389. package/src/tui/app/app-view/floating-panel.jsx +26 -1
  390. package/src/tui/app/btw-controller.mjs +118 -0
  391. package/src/tui/app/create-app-pickers.mjs +2 -0
  392. package/src/tui/app/shell-layout/row-budgets.mjs +11 -1
  393. package/src/tui/app/shell-layout/surfaces.mjs +3 -1
  394. package/src/tui/app/slash-commands.mjs +13 -3
  395. package/src/tui/app/slash-dispatch/session-commands.mjs +19 -6
  396. package/src/tui/app/use-global-key-input/approval-shortcut.mjs +12 -0
  397. package/src/tui/app/use-global-key-input.mjs +5 -12
  398. package/src/tui/app/use-prompt-handlers.mjs +11 -0
  399. package/src/tui/components/BtwPanel.jsx +53 -0
  400. package/src/tui/components/QuestionPanel.jsx +275 -0
  401. package/src/tui/components/question-panel-model.mjs +156 -0
  402. package/src/tui/dist/index.mjs +2413 -1465
  403. package/src/tui/session/context-state.mjs +4 -2
  404. package/src/tui/session/session-api/commands.mjs +10 -0
  405. package/src/tui/session/session-api/controls.mjs +2 -2
  406. package/src/tui/session/session-api/integrations.mjs +9 -2
  407. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  408. package/src/tui/session/session-api/lifecycle.mjs +2 -1
  409. package/src/tui/session/session-api/settings.mjs +6 -0
  410. package/src/tui/session/session-api.mjs +1 -0
  411. package/src/tui/session/tool-approval.mjs +10 -7
  412. package/src/tui/session/transcript-spill.mjs +14 -7
  413. package/src/tui/session/turn-stream.mjs +174 -31
  414. package/src/tui/session/turn.mjs +12 -5
  415. package/src/workflows/default/WORKFLOW.md +3 -1
@@ -1,27 +1,20 @@
1
- import { createTwoFilesPatch } from 'diff';
2
- import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
- import { dirname, isAbsolute, relative, resolve } from 'node:path';
1
+ import { isAbsolute, relative, resolve } from 'node:path';
4
2
  import {
5
3
  _resetTurnWorktreeSnapshotsForTest,
4
+ canonicalPath,
6
5
  createTurnWorktreeSnapshot,
7
- diffTurnWorktreeHead,
8
- freezeTurnWorktreeChanges,
9
6
  noteTurnWorktreeChange,
10
7
  refreshTurnWorktreeSnapshot,
11
8
  recordTurnWorktreeFileChange,
9
+ resumeTurnWorktreeFilePatch,
12
10
  resumeTurnWorktreeSnapshot,
13
- revertTurnWorktreeFile,
14
- revertTurnWorktreePaths,
15
- revertTurnWorktreeSnapshot,
16
11
  } from './turn-worktree-snapshot.mjs';
17
12
  import {
18
13
  clearSessionChangeHistory as clearStoredChangeHistory,
19
14
  loadSessionSnapshotRecords,
20
15
  loadTurnChangeRecord,
21
16
  loadTurnChangeRecords,
22
- loadTurnReviewSummary,
23
17
  loadTurnSnapshotRecord,
24
- saveTurnReviewSummary,
25
18
  saveTurnSnapshotRecord,
26
19
  sessionChangeHistoryCleared,
27
20
  updateTurnChangeRecord,
@@ -29,11 +22,15 @@ import {
29
22
  } from './turn-snapshot-store.mjs';
30
23
  import {
31
24
  applyTurnChangeFiles,
25
+ changeDisplayPatch,
32
26
  changePathKey,
33
27
  findChangeFile,
28
+ isBinaryChangeContent,
34
29
  measureLegacyChangeFile,
35
30
  namedChangePatch,
31
+ repositoryObjectFormat,
36
32
  sessionChangeReview,
33
+ splitPatchSections,
37
34
  storedChangeFile,
38
35
  trackedChangeFile,
39
36
  turnChangesReply,
@@ -47,21 +44,37 @@ import {
47
44
  primeReviewOwner,
48
45
  recordReviewOwner,
49
46
  } from './session-baseline-store.mjs';
50
- import { boundReviewPatch } from './review-diff.mjs';
47
+ import {
48
+ boundReviewPatch,
49
+ createReviewPatchBudget,
50
+ filePatchReply,
51
+ patchOmittedFile,
52
+ REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
53
+ reviewDiffTooLarge,
54
+ reviewSectionTruncated,
55
+ } from './review-diff.mjs';
51
56
  import { PATCH_EDIT_MAX_BYTES } from './bounded-file-read.mjs';
52
57
  import { cleanString as clean } from './clean.mjs';
58
+ import { createLineDiffBudget, lineDiffHunks, unifiedHunksBody } from './bounded-line-diff.mjs';
53
59
 
54
60
  // Turn-scoped review registry.
55
61
  //
56
- // A Git worktree gets a shadow-index snapshot at turn start,
57
- // then compares that immutable tree with the latest worktree state. This makes
58
- // the headline authoritative for apply_patch, shell, and external edits while
59
- // preserving changes that already existed before the turn. Non-Git worktrees
60
- // retain the exact apply_patch tracker below.
62
+ // Every file tool write folds its exact before/after bytes into the session's
63
+ // tracker as ordered own segments per file: a write that starts from the
64
+ // bytes the file's last segment ended with extends that segment, any other
65
+ // opens a new one, so whatever changed the file in between (a foreign edit)
66
+ // is in no segment. The turn review, its change record and every Undo/
67
+ // Reapply/latest-turn revert work from those segments only, reverse-applied
68
+ // newest first against the current disk with conflict detection: a foreign
69
+ // edit outside the turn's hunks survives an Undo, and one overlapping them
70
+ // fails it instead of being overwritten. Each file counts the summed line
71
+ // deltas of its own writes.
72
+ //
73
+ // A Git worktree also gets a shadow-index snapshot at turn start: the durable
74
+ // baseline of the session-wide review.
61
75
  //
62
- // Worker apply_patch trackers remain separate and are frozen into the owning
63
- // Lead turn as attribution metadata. Their stats are never added a second time
64
- // to a worktree snapshot's authoritative totals.
76
+ // Worker trackers remain separate until the worker completes: then its
77
+ // segments, owned paths and counts join the owning Lead turn's.
65
78
 
66
79
  const DISABLED = /^(0|false|off)$/i.test(String(process.env.MIXDOG_TURN_SNAPSHOT || ''));
67
80
  const TURN_CACHE_MAX = 32;
@@ -74,6 +87,9 @@ const MAX_AGENT_REVIEW_BYTES_PER_TURN = 4 * 1024 * 1024;
74
87
  const _turnsBySession = new Map();
75
88
  const _diffTrackersBySession = new Map();
76
89
  let _agentTurnSeq = 0;
90
+ // Orders every tracked write across trackers, so a worker's segments merge into
91
+ // its Lead's in the order the writes happened.
92
+ let _writeSeq = 0;
77
93
  // `${sessionId}\0${checkpointId}` → seals in flight. A sealing turn's change
78
94
  // record is `pending` and its session refuses Undo/Reapply until it lands.
79
95
  const _pendingFreezes = new Map();
@@ -143,28 +159,58 @@ function ownedPathsUnder(root, owned) {
143
159
  return out;
144
160
  }
145
161
 
146
- /** Paths the turn's checkpoint diff itself observed. Only an uncontended
147
- * worktree can attribute them to this session; a contended one falls back to
148
- * the exact tool-owned list. */
149
- function checkpointObservedPaths(tracker) {
150
- if (!worktreeSnapshotUsable(tracker)) return [];
151
- const out = [];
152
- for (const file of tracker.worktreeSnapshot.files || []) {
153
- if (file?.path) out.push(String(file.path).replace(/\\/g, '/'));
154
- if (file?.oldPath) out.push(String(file.oldPath).replace(/\\/g, '/'));
155
- }
156
- return out;
162
+ /** Fold one write's line counts into `stats` (owned entry key → summed
163
+ * counts). A rename moves the source's counts to its destination; a change
164
+ * that reports no counts (an Undo/Reapply fold) adds nothing. */
165
+ function foldOwnStats(stats, raw, source, destination) {
166
+ const measured = Number.isFinite(raw?.additions) || Number.isFinite(raw?.deletions);
167
+ const prior = stats.get(source.key);
168
+ if (destination) stats.delete(source.key);
169
+ const target = destination || source;
170
+ const existing = destination ? stats.get(destination.key) : null;
171
+ if (!prior && !existing && !measured) return;
172
+ stats.set(target.key, {
173
+ path: target.path,
174
+ display: target.display,
175
+ additions: (prior?.additions || 0) + (existing?.additions || 0) + (Number(raw?.additions) || 0),
176
+ deletions: (prior?.deletions || 0) + (existing?.deletions || 0) + (Number(raw?.deletions) || 0),
177
+ });
157
178
  }
158
179
 
159
- /** Everything this session changed under `root`: the files its own tools
160
- * wrote plus every file its checkpoint diffs observed. This is the
161
- * attribution the session-wide Diff and the recorded revert scope share. */
162
- function sessionAttributedPaths(root, tracker) {
163
- const paths = ownedPathsUnder(root, tracker?.ownedPaths);
164
- if (tracker?.worktreeSnapshot && pathKey(tracker.worktreeSnapshot.root) === pathKey(root)) {
165
- for (const path of checkpointObservedPaths(tracker)) paths.add(path);
180
+ function mergeOwnStats(target, source) {
181
+ for (const [key, entry] of source) {
182
+ const prior = target.get(key);
183
+ target.set(
184
+ key,
185
+ prior
186
+ ? { ...prior, additions: prior.additions + entry.additions, deletions: prior.deletions + entry.deletions }
187
+ : { ...entry }
188
+ );
166
189
  }
167
- return paths;
190
+ }
191
+
192
+ /** Lookup of the tracker's own-write counts by a reviewed file path: relative
193
+ * to `root`, or (`display`) the tools' display path a tracked patch shows. */
194
+ function ownCountIndex(tracker, root, { display = false } = {}) {
195
+ const keyOf = (path) => pathKey(display ? path : resolve(root, path));
196
+ const index = new Map();
197
+ for (const entry of tracker?.ownStats?.values() || []) index.set(keyOf(display ? entry.display : entry.path), entry);
198
+ return (path) => (path ? index.get(keyOf(path)) : undefined);
199
+ }
200
+
201
+ /** Files counted by the summed per-call deltas of their own writes (a
202
+ * rename's old path included). A file no own write reported counts for
203
+ * keeps the counts it has. */
204
+ function withOwnCounts(files, find) {
205
+ return files.map((file) => {
206
+ const hits = [find(file.path), file.oldPath ? find(file.oldPath) : undefined].filter(Boolean);
207
+ if (hits.length === 0) return file;
208
+ return {
209
+ ...file,
210
+ additions: hits.reduce((sum, hit) => sum + hit.additions, 0),
211
+ deletions: hits.reduce((sum, hit) => sum + hit.deletions, 0),
212
+ };
213
+ });
168
214
  }
169
215
 
170
216
  // A change whose bytes were never captured (binary/oversized) carries
@@ -219,23 +265,30 @@ function createDiffTracker(meta = {}) {
219
265
  // The seal (refresh, record, freeze) in flight, if any.
220
266
  freezing: null,
221
267
  revision: 0,
222
- baselineByPath: new Map(),
223
- currentByPath: new Map(),
224
- originByCurrentPath: new Map(),
225
- unifiedDiff: '',
226
- patchTruncated: false,
268
+ // Current path key → { segments }: the file's own segments in write
269
+ // order, each `{ before, after, firstSeq, lastSeq }` whose sides are
270
+ // `{ path, displayPath, content }` (content null = absent).
271
+ chains: new Map(),
272
+ // Current path key → { chain, owned, stats } of a chain an Undo restored,
273
+ // kept so a Reapply puts it back.
274
+ undoneChains: new Map(),
275
+ // The review rendered for one revision (trackerView).
276
+ view: null,
227
277
  worktreeSnapshot: null,
228
278
  // Session-owned relative paths. These outlive the before/after buffers on
229
279
  // purpose: they are what scopes a revert once the exact content is gone.
230
280
  ownedPaths: new Set(),
281
+ // Owned entry key → { path, display, additions, deletions }: the summed
282
+ // line deltas of every own write of the file. Outlives the buffers too.
283
+ ownStats: new Map(),
231
284
  };
232
285
  }
233
286
 
234
287
  function releaseDiffTrackerContent(tracker) {
235
288
  if (!tracker) return;
236
- tracker.baselineByPath.clear();
237
- tracker.currentByPath.clear();
238
- tracker.originByCurrentPath.clear();
289
+ tracker.chains.clear();
290
+ tracker.undoneChains.clear();
291
+ tracker.view = null;
239
292
  }
240
293
 
241
294
  function resetDiffTracker(sessionId, meta = {}) {
@@ -248,253 +301,271 @@ function resetDiffTracker(sessionId, meta = {}) {
248
301
 
249
302
  function trackedContentBytes(tracker) {
250
303
  let total = 0;
251
- for (const entry of tracker.baselineByPath.values()) total += entry.content?.length || 0;
252
- for (const entry of tracker.currentByPath.values()) total += entry.content?.length || 0;
304
+ for (const chain of tracker.chains.values()) {
305
+ for (const { before, after } of chain.segments) {
306
+ total += contentByteLength(before.content) + contentByteLength(after.content);
307
+ }
308
+ }
253
309
  return total;
254
310
  }
255
311
 
256
- const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
257
- function decodeTrackedContent(content) {
258
- if (content === null) return '';
259
- try {
260
- return utf8Decoder.decode(content);
261
- } catch {
262
- return null;
312
+ /** A segment that leaves its file where and as it found it. */
313
+ function noopSegment({ before, after }) {
314
+ return pathKey(before.path) === pathKey(after.path) && sameContent(before.content, after.content);
315
+ }
316
+
317
+ /** Join one own segment to the chain of the file it starts at. It extends the
318
+ * chain's last segment when it starts from that segment's exact after bytes;
319
+ * otherwise something else changed the file in between, and it opens a new
320
+ * segment so that change stays out of both. A move onto a path another chain
321
+ * holds lands as a delete of its source and a write of its destination. */
322
+ function appendSegment(tracker, segment) {
323
+ const startKey = pathKey(segment.before.path);
324
+ const endKey = pathKey(segment.after.path);
325
+ if (startKey !== endKey && tracker.chains.has(endKey)) {
326
+ appendSegment(tracker, { ...segment, after: { ...segment.before, content: null } });
327
+ appendSegment(tracker, { ...segment, before: { ...segment.after, content: null } });
328
+ return;
263
329
  }
330
+ const chain = tracker.chains.get(startKey) || { segments: [] };
331
+ tracker.chains.delete(startKey);
332
+ const last = chain.segments.at(-1);
333
+ if (last && sameContent(last.after.content, segment.before.content)) {
334
+ last.after = segment.after;
335
+ last.lastSeq = segment.lastSeq;
336
+ } else {
337
+ chain.segments.push({ ...segment });
338
+ }
339
+ tracker.chains.set(endKey, chain);
340
+ }
341
+
342
+ const isBinarySide = (content) => isOmittedContent(content) || isBinaryChangeContent(content);
343
+
344
+ /** What a chain records, relative to `root`: its segments that change
345
+ * something — of a binary file only the last, since bytes from before a
346
+ * foreign rewrite cannot be restored around it — as steps with the
347
+ * root-relative paths of their sides ('' outside the root). `start`/`end`
348
+ * are the sides the change begins and ends at (a created file has no old
349
+ * side, a deleted one no new side). Null when the chain changes nothing. */
350
+ function chainEdit(chain, root) {
351
+ let segments = chain.segments.filter((segment) => !noopSegment(segment));
352
+ if (segments.length === 0) return null;
353
+ const binary = segments.some(({ before, after }) => [before.content, after.content].some(isBinarySide));
354
+ if (binary) segments = segments.slice(-1);
355
+ const steps = segments.map(({ before, after }) => ({
356
+ before,
357
+ after,
358
+ oldPath: trackedRelativePath(root, before),
359
+ newPath: trackedRelativePath(root, after),
360
+ }));
361
+ const first = steps[0];
362
+ const last = steps.at(-1);
363
+ const start = first.before.content === null ? first.after : first.before;
364
+ const end = last.after.content === null ? last.before : last.after;
365
+ let status = 'M';
366
+ if (first.before.content === null && last.after.content !== null) status = 'A';
367
+ else if (last.after.content === null && first.before.content !== null) status = 'D';
368
+ else if (pathKey(start.path) !== pathKey(end.path)) status = 'R';
369
+ return {
370
+ steps,
371
+ start,
372
+ end,
373
+ status,
374
+ binary,
375
+ inside: steps.every((step) => step.oldPath && step.newPath),
376
+ omitted: steps.some(({ before, after }) => [before.content, after.content].some(isOmittedContent)),
377
+ };
264
378
  }
265
379
 
266
- function renderTrackedPair(before, after) {
267
- const oldDisplay = before?.displayPath || after?.displayPath || 'unknown file';
268
- const newDisplay = after?.displayPath || before?.displayPath || 'unknown file';
269
- const oldGit = `a/${oldDisplay}`;
270
- const newGit = `b/${newDisplay}`;
271
- const oldHeader = before?.content === null ? '/dev/null' : oldGit;
272
- const newHeader = after?.content === null ? '/dev/null' : newGit;
380
+ /** A side's name in the review: relative to `root` inside it, else the
381
+ * tool's display path. */
382
+ const viewPath = (root, entry) => trackedRelativePath(root, entry) || displayPath(entry.displayPath || entry.path);
383
+
384
+ /** One step's display section, named as the review shows it, with its line
385
+ * counts: hunks, a `Binary files … differ` line, or the not-captured note. */
386
+ function renderStep({ before, after }, root, budget) {
387
+ const oldGit = `a/${viewPath(root, before)}`;
388
+ const newGit = `b/${viewPath(root, after)}`;
389
+ const oldHeader = before.content === null ? '/dev/null' : oldGit;
390
+ const newHeader = after.content === null ? '/dev/null' : newGit;
273
391
  let head = `diff --git ${oldGit} ${newGit}\n`;
274
- if (before?.content === null && after?.content !== null) head += 'new file mode 100644\n';
275
- else if (before?.content !== null && after?.content === null) head += 'deleted file mode 100644\n';
276
- const omitted = [before?.content, after?.content].find(isOmittedContent);
392
+ if (before.content === null) head += 'new file mode 100644\n';
393
+ else if (after.content === null) head += 'deleted file mode 100644\n';
394
+ const binaryLine = `Binary files ${oldHeader} and ${newHeader} differ\n`;
395
+ const omitted = [before.content, after.content].find(isOmittedContent);
277
396
  if (omitted) {
278
397
  let change = 'changed';
279
- if (after?.content === null) change = 'deleted';
280
- else if (before?.content === null) change = 'added';
281
- return `${head}binary/large file ${change} (${omitted.size} bytes; content not captured)\nBinary files ${oldHeader} and ${newHeader} differ\n`;
398
+ if (after.content === null) change = 'deleted';
399
+ else if (before.content === null) change = 'added';
400
+ const note = `binary/large file ${change} (${omitted.size} bytes; content not captured)\n`;
401
+ return { text: `${head}${note}${binaryLine}`, additions: 0, deletions: 0 };
282
402
  }
283
- const oldText = decodeTrackedContent(before?.content ?? null);
284
- const newText = decodeTrackedContent(after?.content ?? null);
285
- if (oldText === null || newText === null) {
286
- return `${head}Binary files ${oldHeader} and ${newHeader} differ\n`;
403
+ if ([before.content, after.content].some(isBinaryChangeContent)) {
404
+ return { text: `${head}${binaryLine}`, additions: 0, deletions: 0 };
287
405
  }
288
- try {
289
- const generated = createTwoFilesPatch(oldHeader, newHeader, oldText, newText, '', '', { context: 3, timeout: 100 });
290
- const headerStart = generated.search(/^--- /m);
291
- if (headerStart >= 0) return `${head}${generated.slice(headerStart).replace(/\n*$/, '\n')}`;
292
- } catch {
293
- // A pathological diff timing out must not stall tool completion. Preserve
294
- // the changed-file identity even when a textual hunk cannot be rendered.
406
+ const text = (content) => (content === null ? '' : content.toString('utf8'));
407
+ const { hunks } = lineDiffHunks(text(before.content), text(after.content), budget, [before.content, after.content]);
408
+ let additions = 0;
409
+ let deletions = 0;
410
+ for (const line of hunks.flatMap((hunk) => hunk.lines)) {
411
+ if (line.startsWith('+')) additions += 1;
412
+ else if (line.startsWith('-')) deletions += 1;
295
413
  }
296
- if (before?.content === null || after?.content === null) return head;
297
- if (oldDisplay !== newDisplay) {
298
- return `${head}similarity index 100%\nrename from ${oldDisplay}\nrename to ${newDisplay}\n`;
299
- }
300
- return `${head}Binary files ${oldGit} and ${newGit} differ\n`;
301
- }
302
-
303
- function refreshUnifiedDiff(tracker) {
414
+ return { text: `${head}${unifiedHunksBody(oldHeader, newHeader, hunks)}`, additions, deletions };
415
+ }
416
+
417
+ /** The tracker's own changes as the review shows them: a row per file with
418
+ * its own counts, and the display patch of its segments, named relative to
419
+ * the tracker's patch root (a worker without one: the tools' display
420
+ * paths). Beside a Git root a file outside it is left out, as from the
421
+ * record's undoable files. Cached per revision; `budget` bounds a render. */
422
+ function trackerView(tracker, budget = createLineDiffBudget()) {
423
+ const { root, kind } = trackerPatchRoot(tracker);
424
+ if (tracker.view?.revision === tracker.revision && tracker.view.root === root) return tracker.view;
425
+ const rows = [];
304
426
  if (!tracker.valid) {
305
- tracker.unifiedDiff = '';
306
- return;
307
- }
308
- const renamePairs = new Map();
309
- const pairedDestinations = new Set();
310
- for (const [destinationKey, originKey] of tracker.originByCurrentPath) {
311
- if (
312
- destinationKey === originKey ||
313
- tracker.currentByPath.has(originKey) ||
314
- !tracker.currentByPath.has(destinationKey) ||
315
- !tracker.baselineByPath.has(originKey) ||
316
- tracker.baselineByPath.has(destinationKey)
317
- ) {
318
- continue;
427
+ // The content is gone: which files changed is known, their diff is not.
428
+ for (const path of ownedPathsUnder(root, tracker.ownedPaths)) {
429
+ rows.push({ path, status: 'M', additions: 0, deletions: 0, text: '' });
319
430
  }
320
- renamePairs.set(originKey, destinationKey);
321
- pairedDestinations.add(destinationKey);
322
431
  }
323
- const handled = new Set();
324
- const allKeys = [...new Set([...tracker.baselineByPath.keys(), ...tracker.currentByPath.keys()])].sort(
325
- (left, right) => {
326
- const leftEntry = tracker.currentByPath.get(left) || tracker.baselineByPath.get(left);
327
- const rightEntry = tracker.currentByPath.get(right) || tracker.baselineByPath.get(right);
328
- return String(leftEntry?.displayPath || left).localeCompare(String(rightEntry?.displayPath || right));
329
- }
330
- );
331
- const pairs = [];
332
- for (const key of allKeys) {
333
- if (!handled.add(key) || pairedDestinations.has(key)) continue;
334
- const destinationKey = renamePairs.get(key);
335
- if (destinationKey) handled.add(destinationKey);
336
- const before = tracker.baselineByPath.get(key);
337
- const after = tracker.currentByPath.get(destinationKey || key);
338
- if (!before && !after) continue;
339
- pairs.push([before || null, after || null]);
340
- }
341
- let unifiedDiff = '';
342
- for (const [before, after] of pairs) {
343
- const beforeContent = before?.content ?? null;
344
- const afterContent = after?.content ?? null;
345
- const samePath = before?.displayPath === after?.displayPath;
346
- if (samePath && sameContent(beforeContent, afterContent)) continue;
347
- const rendered = renderTrackedPair(
348
- before || { displayPath: after?.displayPath, content: null },
349
- after || { displayPath: before?.displayPath, content: null }
350
- );
351
- unifiedDiff += rendered;
352
- }
353
- const bounded = boundReviewPatch(unifiedDiff, MAX_PATCH_BYTES);
354
- tracker.unifiedDiff = bounded.patch;
355
- tracker.patchTruncated = bounded.truncated;
356
- }
357
-
358
- function trackedPairs(tracker) {
359
- const pairs = [];
360
- const handledOrigins = new Set();
361
- for (const [destinationKey, after] of tracker.currentByPath) {
362
- const originKey = tracker.originByCurrentPath.get(destinationKey) || destinationKey;
363
- handledOrigins.add(originKey);
364
- pairs.push({
365
- originKey,
366
- destinationKey,
367
- before: tracker.baselineByPath.get(originKey) || null,
368
- after,
432
+ for (const chain of tracker.chains.values()) {
433
+ const edit = chainEdit(chain, root);
434
+ if (!edit || (kind === 'git' && !edit.inside)) continue;
435
+ const rendered = edit.steps.map((step) => renderStep(step, root, budget));
436
+ const path = viewPath(root, edit.end);
437
+ const oldPath = viewPath(root, edit.start);
438
+ const omitted = edit.steps.flatMap(({ before, after }) => [before.content, after.content]).find(isOmittedContent);
439
+ rows.push({
440
+ path,
441
+ ...(oldPath !== path ? { oldPath } : {}),
442
+ status: edit.status,
443
+ additions: rendered.reduce((sum, step) => sum + step.additions, 0),
444
+ deletions: rendered.reduce((sum, step) => sum + step.deletions, 0),
445
+ ...(edit.binary ? { binary: true } : {}),
446
+ // Its bytes were never captured: listed, never diffed or undone.
447
+ ...(omitted ? { omitted: true, size: omitted.size } : {}),
448
+ text: rendered.map((step) => step.text).join(''),
369
449
  });
370
450
  }
371
- for (const [originKey, before] of tracker.baselineByPath) {
372
- if (handledOrigins.has(originKey)) continue;
373
- pairs.push({ originKey, destinationKey: originKey, before, after: null });
374
- }
375
- return pairs;
376
- }
377
-
378
- function safeTrackedTarget(worktree, value) {
379
- const root = resolve(clean(worktree));
380
- const target = isAbsolute(clean(value)) ? resolve(clean(value)) : resolve(root, clean(value));
381
- const rel = relative(root, target);
382
- if (!rel || rel === '.' || rel.startsWith('..') || isAbsolute(rel)) {
383
- throw new Error('turn review file path is outside the worktree');
384
- }
385
- return target;
386
- }
387
-
388
- function pairMatchesFile(pair, worktree, file) {
389
- const requested = safeTrackedTarget(worktree, file);
390
- return [pair.before, pair.after].some((entry) => {
391
- if (!entry) return false;
392
- const values = [entry.path, entry.displayPath].filter(Boolean);
393
- return values.some((value) => safeTrackedTarget(worktree, value) === requested);
394
- });
395
- }
396
-
397
- async function currentTrackedContent(path) {
398
- try {
399
- const stat = await lstat(path);
400
- if (!stat.isFile() || stat.isSymbolicLink()) {
401
- throw new Error('turn review revert only supports regular files');
402
- }
403
- return await readFile(path);
404
- } catch (error) {
405
- if (error?.code === 'ENOENT') return null;
406
- throw error;
407
- }
451
+ rows.sort((left, right) => left.path.localeCompare(right.path));
452
+ const bounded = boundReviewPatch(rows.map((row) => row.text).join(''), MAX_PATCH_BYTES);
453
+ const counted = root ? ownCountIndex(tracker, root) : ownCountIndex(tracker, '', { display: true });
454
+ tracker.view = {
455
+ revision: tracker.revision,
456
+ root,
457
+ files: withOwnCounts(
458
+ rows.map(({ text, ...row }) => row),
459
+ counted
460
+ ),
461
+ patch: bounded.patch,
462
+ truncated: bounded.truncated,
463
+ };
464
+ return tracker.view;
408
465
  }
409
466
 
410
- async function validateTrackedPair(pair, worktree) {
411
- const omitted = [pair.before?.content, pair.after?.content].find(isOmittedContent);
412
- if (omitted) {
413
- const display = pair.before?.displayPath || pair.after?.displayPath || 'unknown file';
414
- throw new Error(
415
- `turn review revert is unavailable for ${display}: binary/large file content was not captured (${omitted.size} bytes)`
416
- );
417
- }
418
- const beforePath = safeTrackedTarget(
419
- worktree,
420
- pair.before?.path || pair.before?.displayPath || pair.after?.path || pair.after?.displayPath
421
- );
422
- const afterPath = safeTrackedTarget(
423
- worktree,
424
- pair.after?.path || pair.after?.displayPath || pair.before?.path || pair.before?.displayPath
467
+ /** True when `value` (absolute, or relative to `root`) names a path the chain
468
+ * touched. */
469
+ function chainMatchesPath(chain, root, value) {
470
+ const target = clean(value);
471
+ if (!target) return false;
472
+ const key = changePathKey(relative(root, isAbsolute(target) ? resolve(target) : resolve(root, target)));
473
+ return chain.segments.some((segment) =>
474
+ [segment.before, segment.after].some((entry) => {
475
+ const path = trackedRelativePath(root, entry);
476
+ return Boolean(path) && changePathKey(path) === key;
477
+ })
425
478
  );
426
- const expectedAfter = pair.after?.content ?? null;
427
- const actualAfter = await currentTrackedContent(afterPath);
428
- if (!sameContent(actualAfter, expectedAfter)) {
429
- throw new Error('turn review revert refused because the file changed after this session edit');
430
- }
431
- if (beforePath !== afterPath && (await currentTrackedContent(beforePath)) !== null) {
432
- throw new Error('turn review revert refused because the original path changed after this session edit');
433
- }
434
- return { beforePath, afterPath };
435
479
  }
436
480
 
437
- async function restoreTrackedPair(pair, paths) {
438
- if (paths.afterPath !== paths.beforePath) await rm(paths.afterPath, { force: true });
439
- if (pair.before?.content == null) {
440
- await rm(paths.beforePath, { force: true });
441
- } else {
442
- await mkdir(dirname(paths.beforePath), { recursive: true });
443
- await writeFile(paths.beforePath, pair.before.content);
481
+ /** The owned entries (and their counts) of every path a chain touched. */
482
+ function chainOwnership(tracker, chain) {
483
+ const owned = [
484
+ ...new Set(
485
+ chain.segments
486
+ .flatMap((segment) => [segment.before, segment.after])
487
+ .map((entry) => ownedPathEntry(entry.path, displayPath(entry.displayPath || entry.path)))
488
+ ),
489
+ ].filter((entry) => tracker.ownedPaths.has(entry));
490
+ const stats = owned
491
+ .map((entry) => [pathKey(entry), tracker.ownStats.get(pathKey(entry))])
492
+ .filter(([, entry]) => entry);
493
+ return { owned, stats };
494
+ }
495
+
496
+ /** An Undo reverse-applied these chains: the turn no longer changes them, so
497
+ * they leave its review. Each is kept for a Reapply. */
498
+ function undoTrackedChains(tracker, chains) {
499
+ for (const [key, chain] of tracker.chains) {
500
+ if (!chains.includes(chain)) continue;
501
+ const ownership = chainOwnership(tracker, chain);
502
+ tracker.undoneChains.set(key, { chain, ...ownership });
503
+ for (const entry of ownership.owned) tracker.ownedPaths.delete(entry);
504
+ for (const [statKey] of ownership.stats) tracker.ownStats.delete(statKey);
505
+ tracker.chains.delete(key);
444
506
  }
507
+ tracker.revision += 1;
445
508
  }
446
509
 
447
- function commitTrackedPairRestore(tracker, pair) {
448
- tracker.currentByPath.delete(pair.destinationKey);
449
- tracker.currentByPath.delete(pair.originKey);
450
- tracker.originByCurrentPath.delete(pair.destinationKey);
451
- tracker.originByCurrentPath.delete(pair.originKey);
452
- if (pair.before) tracker.currentByPath.set(pair.originKey, { ...pair.before });
453
- }
454
-
455
- async function revertTrackedPairs(tracker, worktree, pairs) {
456
- if (!tracker?.valid || pairs.length === 0) {
457
- throw new Error('turn review tracked file snapshot is unavailable');
458
- }
459
- // Validate every current file before the first write so an external edit
460
- // cannot leave a turn-wide revert half-applied.
461
- const validated = [];
462
- for (const pair of pairs) validated.push(await validateTrackedPair(pair, worktree));
463
- for (let index = 0; index < pairs.length; index += 1) {
464
- await restoreTrackedPair(pairs[index], validated[index]);
465
- commitTrackedPairRestore(tracker, pairs[index]);
510
+ /** A Reapply forward-applied the undone chains matching `files` again. */
511
+ function redoTrackedChains(tracker, root, files) {
512
+ for (const [key, undone] of tracker.undoneChains) {
513
+ const matches = files.some((file) =>
514
+ [file.path, file.oldPath].some((path) => chainMatchesPath(undone.chain, root, path))
515
+ );
516
+ if (!matches) continue;
517
+ tracker.undoneChains.delete(key);
518
+ tracker.chains.set(key, undone.chain);
519
+ for (const entry of undone.owned) tracker.ownedPaths.add(entry);
520
+ for (const [statKey, entry] of undone.stats) tracker.ownStats.set(statKey, entry);
466
521
  }
467
522
  tracker.revision += 1;
468
- refreshUnifiedDiff(tracker);
469
- if (!tracker.unifiedDiff) releaseDiffTrackerContent(tracker);
470
- // The absolute paths now back at their turn-start bytes.
471
- return [...new Set(validated.flatMap(({ beforePath, afterPath }) => [beforePath, afterPath]))];
472
523
  }
473
524
 
474
- /** A path's first observed state is its baseline; later changes never replace it. */
475
- function recordFirstBaseline(tracker, key, entry) {
476
- if (!tracker.currentByPath.has(key) && !tracker.baselineByPath.has(key)) tracker.baselineByPath.set(key, entry);
525
+ /** A new write supersedes whatever an Undo set aside for the paths it
526
+ * touches (`keys`). */
527
+ function dropUndoneChains(tracker, keys) {
528
+ for (const [key, undone] of tracker.undoneChains) {
529
+ const touched = undone.chain.segments.some(
530
+ (segment) => keys.has(pathKey(segment.before.path)) || keys.has(pathKey(segment.after.path))
531
+ );
532
+ if (touched) tracker.undoneChains.delete(key);
533
+ }
477
534
  }
478
535
 
479
536
  /**
480
537
  * Fold exact committed before/after file states into a session's current turn.
481
538
  * A change is `{ path, displayPath, before, after }`; a rename additionally
482
- * carries `{ newPath, newDisplayPath }`.
539
+ * carries `{ newPath, newDisplayPath }`. `budget` is the line-diff budget of
540
+ * the tool call the changes come from. Returns the turn's review patch.
483
541
  */
484
- export function recordTurnDiffChanges(sessionId, changes = []) {
542
+ export function recordTurnDiffChanges(sessionId, changes = [], budget = createLineDiffBudget()) {
485
543
  if (DISABLED) return '';
486
544
  const id = clean(sessionId);
487
545
  if (!id) return '';
488
546
  const tracker = _diffTrackersBySession.get(id) || resetDiffTracker(id);
489
- const list = Array.isArray(changes) ? changes : [];
490
547
  const ownerTracker = tracker?.ownerSessionId ? _diffTrackersBySession.get(tracker.ownerSessionId) : tracker;
491
548
  const snapshot = ownerTracker?.worktreeSnapshot;
549
+ const raws = Array.isArray(changes) ? changes : [];
550
+ // Git reports the turn's root canonically (macOS /var -> /private/var):
551
+ // each written path is spelled the same way, or it never relates to it.
552
+ const list = snapshot?.root ? raws.map(canonicalChangePaths) : raws;
492
553
  // Independent of this tracker's validity: the session review needs every
493
554
  // write's first-seen state even once the turn's buffers are gone.
494
555
  captureReviewBaselines(id, tracker, ownerTracker, list, snapshot);
495
- if (!tracker?.valid || tracker.sealed) return tracker?.unifiedDiff || '';
496
- foldTrackedChanges(tracker, list, snapshot);
497
- return tracker.unifiedDiff;
556
+ if (!tracker) return '';
557
+ if (tracker.valid && !tracker.sealed) foldTrackedChanges(tracker, list, snapshot);
558
+ return trackerView(tracker, budget).patch;
559
+ }
560
+
561
+ function canonicalChangePaths(raw) {
562
+ const path = clean(raw?.path);
563
+ const newPath = clean(raw?.newPath);
564
+ return {
565
+ ...raw,
566
+ ...(isAbsolute(path) ? { path: canonicalPath(path) } : {}),
567
+ ...(isAbsolute(newPath) ? { newPath: canonicalPath(newPath) } : {}),
568
+ };
498
569
  }
499
570
 
500
571
  const insidePath = (root, path) => {
@@ -528,9 +599,9 @@ function captureReviewBaselines(sessionId, tracker, ownerTracker, list, snapshot
528
599
  captureSessionBaselines(sessionId, changes, { owner: tracker?.ownerSessionId || '' });
529
600
  }
530
601
 
531
- /** Fold exact before/after states into `tracker`. `snapshot` — the owning
532
- * Lead turn's Git baseline, when tools are writing — learns each written
533
- * path's first-before bytes. */
602
+ /** Fold exact before/after states into `tracker` as own segments (see
603
+ * appendSegment). `snapshot` — the owning Lead turn's Git baseline, when
604
+ * tools are writing — learns each written path's first-before bytes. */
534
605
  function foldTrackedChanges(tracker, list, snapshot) {
535
606
  // The content ceiling is enforced BEFORE anything is stored: an over-budget
536
607
  // batch invalidates the tracker up front instead of retaining its buffers.
@@ -559,63 +630,67 @@ function foldTrackedChanges(tracker, list, snapshot) {
559
630
  // attribution a shared worktree cannot otherwise reconstruct. It keeps the
560
631
  // ABSOLUTE file: the display path is relative to the session cwd, which
561
632
  // is not necessarily the repository root the scoped diff runs from.
562
- tracker.ownedPaths.add(ownedPathEntry(sourcePath, sourceDisplay));
633
+ const sourceEntry = ownedPathEntry(sourcePath, sourceDisplay);
634
+ tracker.ownedPaths.add(sourceEntry);
635
+ let destination = null;
563
636
  if (destinationPath) {
564
- tracker.ownedPaths.add(ownedPathEntry(destinationPath, displayPath(raw?.newDisplayPath || destinationPath)));
637
+ const display = displayPath(raw?.newDisplayPath || destinationPath);
638
+ const entry = ownedPathEntry(destinationPath, display);
639
+ tracker.ownedPaths.add(entry);
640
+ destination = { key: pathKey(entry), path: entry, display };
565
641
  }
642
+ foldOwnStats(
643
+ tracker.ownStats,
644
+ raw,
645
+ { key: pathKey(sourceEntry), path: sourceEntry, display: sourceDisplay },
646
+ destination
647
+ );
566
648
  if (!storeContent) continue;
567
649
  const after = contentBuffer(raw?.after);
568
- if (!destinationPath) {
569
- if (before === null && after !== null) {
570
- tracker.originByCurrentPath.delete(sourceKey);
571
- tracker.currentByPath.set(sourceKey, {
572
- path: sourcePath,
573
- displayPath: sourceDisplay,
574
- content: after,
575
- });
576
- continue;
577
- }
578
- if (before !== null && after === null) {
579
- if (!tracker.currentByPath.delete(sourceKey) && !tracker.baselineByPath.has(sourceKey)) {
580
- tracker.baselineByPath.set(sourceKey, {
581
- path: sourcePath,
582
- displayPath: sourceDisplay,
583
- content: before,
584
- });
585
- }
586
- tracker.originByCurrentPath.delete(sourceKey);
587
- continue;
588
- }
589
- if (before === null && after === null) continue;
590
- recordFirstBaseline(tracker, sourceKey, { path: sourcePath, displayPath: sourceDisplay, content: before });
591
- tracker.currentByPath.set(sourceKey, {
592
- path: sourcePath,
593
- displayPath: sourceDisplay,
594
- content: after,
595
- });
596
- continue;
597
- }
598
-
599
- const destinationKey = pathKey(destinationPath);
600
- const destinationDisplay = displayPath(raw?.newDisplayPath || destinationPath);
601
- recordFirstBaseline(tracker, sourceKey, { path: sourcePath, displayPath: sourceDisplay, content: before });
602
- const originKey = tracker.originByCurrentPath.get(sourceKey) || sourceKey;
603
- tracker.currentByPath.delete(sourceKey);
604
- tracker.originByCurrentPath.delete(sourceKey);
605
- tracker.currentByPath.set(destinationKey, {
606
- path: destinationPath,
607
- displayPath: destinationDisplay,
608
- content: after,
650
+ if (!destinationPath && before === null && after === null) continue;
651
+ dropUndoneChains(tracker, new Set([sourceKey, ...(destination ? [pathKey(destinationPath)] : [])]));
652
+ const seq = (_writeSeq += 1);
653
+ appendSegment(tracker, {
654
+ before: { path: sourcePath, displayPath: sourceDisplay, content: before },
655
+ after: destination
656
+ ? { path: destinationPath, displayPath: destination.display, content: after }
657
+ : { path: sourcePath, displayPath: sourceDisplay, content: after },
658
+ firstSeq: seq,
659
+ lastSeq: seq,
609
660
  });
610
- tracker.originByCurrentPath.delete(destinationKey);
611
- if (destinationKey !== originKey) tracker.originByCurrentPath.set(destinationKey, originKey);
612
661
  }
613
662
  tracker.revision += 1;
614
663
  if (trackedContentBytes(tracker) > MAX_TRACKED_CONTENT_BYTES) {
615
664
  tracker.valid = false;
616
665
  releaseDiffTrackerContent(tracker);
617
666
  }
618
- refreshUnifiedDiff(tracker);
667
+ }
668
+
669
+ /** Join a completed worker's own segments to its Lead's tracker: both sets
670
+ * replay in write order, so a worker write between two Lead writes of one
671
+ * file continues their segment instead of reading as a foreign gap. A
672
+ * worker whose content was dropped drops the Lead's exact content too. */
673
+ function mergeTrackedContent(target, source) {
674
+ // The worker's owned paths and counts joined as well: any view is stale.
675
+ target.revision += 1;
676
+ if (!target.valid) return;
677
+ if (!source.valid) {
678
+ target.valid = false;
679
+ releaseDiffTrackerContent(target);
680
+ return;
681
+ }
682
+ const segmentsOf = (tracker) => [...tracker.chains.values()].flatMap((chain) => chain.segments);
683
+ const written = new Set(
684
+ segmentsOf(source).flatMap((segment) => [pathKey(segment.before.path), pathKey(segment.after.path)])
685
+ );
686
+ const segments = [...segmentsOf(target), ...segmentsOf(source)].sort((left, right) => left.firstSeq - right.firstSeq);
687
+ target.chains = new Map();
688
+ for (const segment of segments) appendSegment(target, segment);
689
+ dropUndoneChains(target, written);
690
+ if (trackedContentBytes(target) > MAX_TRACKED_CONTENT_BYTES) {
691
+ target.valid = false;
692
+ releaseDiffTrackerContent(target);
693
+ }
619
694
  }
620
695
 
621
696
  function trimAgentReviews(turn) {
@@ -680,13 +755,14 @@ export async function beginTurnSnapshot(worktree, sessionId, options = {}) {
680
755
  // Turn start and Undo/Reapply share the per-session lock: a start waits for
681
756
  // an in-flight mutation, and a mutation queued after this start re-checks
682
757
  // under the lock and finds the turn running.
683
- // A late-worker re-seal still queued for the previous turn runs first, in
684
- // this same step: the worker's edit stays in that turn's record and nothing
685
- // the new turn writes can reach it. (A queued re-seal implies a chain.)
758
+ // Late-worker re-seals still queued for the previous turn run first, in
759
+ // this same step — including one a worker queued while an earlier one ran —
760
+ // so the workers' edits stay in that turn's record and nothing the new turn
761
+ // writes can reach it. (A queued re-seal implies a chain.)
686
762
  const { token, tracker } = _changeMutations.has(ownerSessionId)
687
763
  ? await serializeChangeMutation(ownerSessionId, async () => {
688
764
  const previous = _diffTrackersBySession.get(ownerSessionId);
689
- if (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
765
+ while (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
690
766
  return openTurnSnapshot(worktree, ownerSessionId, options);
691
767
  })
692
768
  : openTurnSnapshot(worktree, ownerSessionId, options);
@@ -780,24 +856,29 @@ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {})
780
856
  };
781
857
  }
782
858
 
783
- /** Freeze successful worker apply_patch diffs into their owning Lead turn. */
859
+ /** Freeze successful worker apply_patch diffs into their owning Lead turn.
860
+ * Everything a seal reads — the agent entry, the worker's owned paths and a
861
+ * late re-seal — is published before this returns, so a seal started right
862
+ * after it already includes the worker. */
784
863
  export function completeAgentTurnReview(handle, patches = []) {
785
864
  if (!handle || handle.completed === true) return false;
786
865
  handle.completed = true;
787
- const turn = _turnsBySession.get(clean(handle.ownerSessionId));
866
+ const ownerSessionId = clean(handle.ownerSessionId);
788
867
  const key = clean(handle.sessionId) || handle.id;
868
+ const turn = _turnsBySession.get(ownerSessionId);
789
869
  const tracker = _diffTrackersBySession.get(key);
790
870
  const matchingTracker =
791
- tracker && tracker.ownerSessionId === clean(handle.ownerSessionId) && tracker.ownerGeneration === handle.generation;
871
+ tracker && tracker.ownerSessionId === ownerSessionId && tracker.ownerGeneration === handle.generation;
792
872
  // A worker that runs its own session turn replaces the bound tracker with
793
873
  // its turn's own one (no owner). beginAgentTurnReview reset any older
794
874
  // tracker, so an unowned one here holds this execution's edits.
795
875
  const source = matchingTracker || (tracker && !tracker.ownerSessionId) ? tracker : null;
876
+ const ownerTracker = _diffTrackersBySession.get(ownerSessionId);
796
877
  try {
797
878
  if (!turn || turn.generation !== handle.generation) return false;
798
879
  const prior = turn.agents.get(key);
799
880
  const tracked = Boolean(source) && source.revision > 0;
800
- const patch = tracked ? source.unifiedDiff : mergePatches(patches);
881
+ const patch = tracked ? trackerView(source).patch : mergePatches(patches);
801
882
  if (!patch) {
802
883
  if (tracked && prior) {
803
884
  turn.agents.delete(key);
@@ -812,15 +893,17 @@ export function completeAgentTurnReview(handle, patches = []) {
812
893
  tag: handle.tag || prior?.tag || null,
813
894
  patch: tracked ? patch : mergePatches([prior?.patch, patch]),
814
895
  });
815
- const ownerTracker = _diffTrackersBySession.get(clean(handle.ownerSessionId));
816
896
  if (source && ownerTracker && ownerTracker !== source) {
817
897
  for (const path of source.ownedPaths) ownerTracker.ownedPaths.add(path);
898
+ mergeOwnStats(ownerTracker.ownStats, source.ownStats);
899
+ mergeTrackedContent(ownerTracker, source);
818
900
  }
819
901
  trimAgentReviews(turn);
820
- // A worker that finishes after its Lead turn sealed re-seals that turn's
821
- // change record (its edits are part of the turn's net worktree diff).
902
+ // A worker that finishes after its Lead turn sealed (or while it seals)
903
+ // re-seals that turn's change record (its edits are part of the turn's net
904
+ // worktree diff).
822
905
  if (ownerTracker && ownerTracker !== tracker && ownerTracker.ownerGeneration === handle.generation) {
823
- resealTurn(clean(handle.ownerSessionId), ownerTracker);
906
+ resealTurn(ownerSessionId, ownerTracker);
824
907
  }
825
908
  return true;
826
909
  } finally {
@@ -839,7 +922,7 @@ export function completeAgentTurnReview(handle, patches = []) {
839
922
  async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
840
923
  const snapshot = tracker?.worktreeSnapshot;
841
924
  if (!ownerSessionId || tracker.discarded || !snapshot?.baselineTree || !snapshot.root) return;
842
- const owned = [...sessionAttributedPaths(snapshot.root, tracker)];
925
+ const owned = [...ownedPathsUnder(snapshot.root, tracker.ownedPaths)];
843
926
  if (owned.length === 0) return;
844
927
  try {
845
928
  await saveTurnSnapshotRecord(ownerSessionId, {
@@ -868,10 +951,11 @@ function attributedAgents(ownerSessionId, tracker) {
868
951
  }));
869
952
  }
870
953
 
871
- /** Path of a tracked entry relative to `root`, or '' outside it. */
954
+ /** Path of a tracked entry relative to `root`, or '' outside it (or without
955
+ * a root). */
872
956
  function trackedRelativePath(root, entry) {
873
957
  const value = clean(entry?.path) || clean(entry?.displayPath);
874
- if (!value) return '';
958
+ if (!root || !value) return '';
875
959
  const rel = relative(root, isAbsolute(value) ? resolve(value) : resolve(root, value));
876
960
  if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return '';
877
961
  return rel.replace(/\\/g, '/');
@@ -879,42 +963,79 @@ function trackedRelativePath(root, entry) {
879
963
 
880
964
  /** A record file for an edit the worktree root cannot replay: outside it, or
881
965
  * too large/binary to capture. Display-only (`viewPatch`), never undone. */
882
- function untrackableRecordFile(pair, { before, after, oldPath, newPath, omitted }) {
883
- let status = 'M';
884
- if (before === null) status = 'A';
885
- else if (after === null) status = 'D';
886
- else if (oldPath !== newPath) status = 'R';
887
- const shownPath =
888
- (status === 'D' ? oldPath : newPath) || displayPath(pair.after?.displayPath || pair.before?.displayPath);
889
- const named = omitted ? null : namedChangePatch({ before, after, oldName: shownPath, newName: shownPath });
966
+ function untrackableRecordFile(edit, root, budget) {
967
+ const path = viewPath(root, edit.end);
968
+ const oldPath = viewPath(root, edit.start);
969
+ const named = edit.omitted
970
+ ? []
971
+ : edit.steps.map(({ before, after }) =>
972
+ namedChangePatch({ before: before.content, after: after.content, oldName: path, newName: path, budget })
973
+ );
974
+ const viewPatch = named.map((step) => step.patch).join('');
890
975
  return {
891
- path: shownPath,
892
- ...(status === 'R' && oldPath ? { oldPath } : {}),
893
- status,
894
- additions: named?.additions || 0,
895
- deletions: named?.deletions || 0,
896
- ...(named?.patch ? { viewPatch: named.patch } : {}),
897
- ...(omitted ? { binary: true } : {}),
976
+ path,
977
+ ...(oldPath !== path ? { oldPath } : {}),
978
+ status: edit.status,
979
+ additions: named.reduce((sum, step) => sum + step.additions, 0),
980
+ deletions: named.reduce((sum, step) => sum + step.deletions, 0),
981
+ ...(viewPatch ? { viewPatch } : {}),
982
+ ...(edit.omitted ? { binary: true } : {}),
898
983
  revertable: false,
899
- reason: omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
984
+ reason: edit.omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
900
985
  };
901
986
  }
902
987
 
903
- /** The exact before/after pair of a tracked entry, or null when it changed nothing. */
904
- function recordedPair(pair, root) {
905
- const before = pair.before?.content ?? null;
906
- const after = pair.after?.content ?? null;
907
- if (before === null && after === null) return null;
908
- const oldPath = trackedRelativePath(root, pair.before || pair.after);
909
- const newPath = trackedRelativePath(root, pair.after || pair.before);
910
- if (oldPath === newPath && sameContent(before, after)) return null;
911
- return { before, after, oldPath, newPath, omitted: [before, after].some(isOmittedContent) };
988
+ /** The root a tracker's own patches are relative to, and a getter for the
989
+ * object format `git apply` hashes their binary content with there (one Git
990
+ * call, made only when asked). */
991
+ function trackerPatchRoot(tracker, fallback = '') {
992
+ const git = Boolean(clean(tracker?.worktreeSnapshot?.root));
993
+ const root = git ? tracker.worktreeSnapshot.root : clean(tracker?.worktreeRoot) || clean(fallback);
994
+ let format = null;
995
+ const objectFormat = () => {
996
+ format ||= git ? repositoryObjectFormat(root) : Promise.resolve('sha1');
997
+ return format;
998
+ };
999
+ return { root: root ? resolve(root) : '', kind: git ? 'git' : 'tracked', objectFormat };
912
1000
  }
913
1001
 
914
- /** Record files of a non-Git turn, from the exact apply_patch buffers. */
915
- function trackedRecordFiles(tracker, root) {
1002
+ /** One chain's edit (chainEdit) as a record file: one replayable patch
1003
+ * section per own segment, oldest first, or a view-only entry when it lies
1004
+ * outside the root or its bytes were never captured. `objectFormat` hashes a
1005
+ * binary patch, `budget` bounds the line diff. */
1006
+ function editChangeFile(edit, root, objectFormat, budget) {
1007
+ if (edit.omitted || !edit.inside) return untrackableRecordFile(edit, root, budget);
1008
+ const sections = edit.steps.map(({ before, after, oldPath, newPath }) =>
1009
+ trackedChangeFile({ oldPath, newPath, before: before.content, after: after.content, objectFormat, budget })
1010
+ );
1011
+ if (sections.length === 1) return sections[0];
1012
+ const path = trackedRelativePath(root, edit.end);
1013
+ const oldPath = trackedRelativePath(root, edit.start);
1014
+ const base = {
1015
+ path,
1016
+ ...(oldPath !== path ? { oldPath } : {}),
1017
+ status: edit.status,
1018
+ additions: sections.reduce((sum, section) => sum + section.additions, 0),
1019
+ deletions: sections.reduce((sum, section) => sum + section.deletions, 0),
1020
+ };
1021
+ const blocked = sections.find((section) => section.revertable !== true);
1022
+ if (blocked) return { ...base, revertable: false, reason: blocked.reason };
1023
+ return { ...base, revertable: true, patch: Buffer.concat(sections.map((section) => section.patch)) };
1024
+ }
1025
+
1026
+ /** The object format the binary patches of `edits` hash with: resolved (one
1027
+ * Git call) only when one of them replays binary content. */
1028
+ async function editsObjectFormat(edits, patchRoot) {
1029
+ const binary = edits.some((edit) => edit.binary && edit.inside && !edit.omitted);
1030
+ return binary ? await patchRoot.objectFormat() : 'sha1';
1031
+ }
1032
+
1033
+ /** Record files of the tracker's own changes. `outside` are those beyond the
1034
+ * root (view-only beside a Git turn's files). */
1035
+ async function ownRecordFiles(tracker, patchRoot) {
1036
+ const { root } = patchRoot;
916
1037
  if (!tracker.valid) {
917
- return [...ownedPathsUnder(root, tracker.ownedPaths)].map((path) => ({
1038
+ const files = [...ownedPathsUnder(root, tracker.ownedPaths)].map((path) => ({
918
1039
  path,
919
1040
  status: 'M',
920
1041
  additions: 0,
@@ -922,63 +1043,41 @@ function trackedRecordFiles(tracker, root) {
922
1043
  revertable: false,
923
1044
  reason: 'file content exceeded the undo tracking limit',
924
1045
  }));
1046
+ return { files, outside: [] };
925
1047
  }
1048
+ const edits = [...tracker.chains.values()].map((chain) => chainEdit(chain, root)).filter(Boolean);
1049
+ const objectFormat = await editsObjectFormat(edits, patchRoot);
1050
+ // One line-diff budget for the whole seal pass.
1051
+ const budget = createLineDiffBudget();
926
1052
  const files = [];
927
- for (const pair of trackedPairs(tracker)) {
928
- const edit = recordedPair(pair, root);
929
- if (!edit) continue;
930
- if (edit.omitted || !edit.oldPath || !edit.newPath) {
931
- files.push(untrackableRecordFile(pair, edit));
932
- continue;
933
- }
934
- files.push(
935
- trackedChangeFile({ oldPath: edit.oldPath, newPath: edit.newPath, before: edit.before, after: edit.after })
936
- );
937
- }
938
- return files;
939
- }
940
-
941
- /** Edits of a Git turn that lie outside its worktree root: the baseline diff
942
- * never sees them, so they are recorded view-only beside the Git files. */
943
- function outsideRecordFiles(tracker, root) {
944
- if (!tracker.valid) return [];
945
- const files = [];
946
- for (const pair of trackedPairs(tracker)) {
947
- const edit = recordedPair(pair, root);
948
- if (edit && (!edit.oldPath || !edit.newPath)) {
949
- files.push(untrackableRecordFile(pair, edit));
950
- }
951
- }
952
- return files;
1053
+ const outside = [];
1054
+ for (const edit of edits) (edit.inside ? files : outside).push(editChangeFile(edit, root, objectFormat, budget));
1055
+ return { files, outside };
953
1056
  }
954
1057
 
955
- /** Freeze the turn's net diff into its durable change record. A Git turn
956
- * records the baseline→end worktree diff (workers' edits included, scoped to
957
- * session-owned paths when the worktree is shared); a non-Git turn records
958
- * the Lead's exact apply_patch pairs. */
1058
+ /** Freeze the turn's own changes into its durable change record: per file,
1059
+ * the session's own segments (its completed workers' included) as one
1060
+ * replayable patch, never the worktree's net diff, counted by the summed
1061
+ * deltas of those writes (`counts: 'own'`; older records without the marker
1062
+ * carry net worktree patches and counts). */
959
1063
  async function freezeTurnChanges(ownerSessionId, tracker, agents) {
960
1064
  const checkpointId = clean(tracker.checkpointId);
961
1065
  if (!checkpointId) return null;
962
- let frozen = null;
963
- if (tracker.worktreeSnapshot) {
964
- const scope = worktreeSnapshotUsable(tracker)
965
- ? null
966
- : [...ownedPathsUnder(tracker.worktreeSnapshot.root, tracker.ownedPaths)];
967
- const result = await freezeTurnWorktreeChanges(tracker.worktreeSnapshot, scope);
968
- if (result) {
969
- frozen = { kind: 'git', ...result, outside: outsideRecordFiles(tracker, tracker.worktreeSnapshot.root) };
970
- }
971
- } else if (tracker.worktreeRoot) {
972
- frozen = { kind: 'tracked', root: tracker.worktreeRoot, files: trackedRecordFiles(tracker, tracker.worktreeRoot) };
973
- }
974
- if (!frozen) return null;
1066
+ const patchRoot = trackerPatchRoot(tracker);
1067
+ if (!patchRoot.root) return null;
1068
+ const { files, outside } = await ownRecordFiles(tracker, patchRoot);
1069
+ const git = patchRoot.kind === 'git';
1070
+ const counted = ownCountIndex(tracker, patchRoot.root);
975
1071
  const record = await upsertTurnChangeRecord(ownerSessionId, {
976
1072
  checkpointId,
977
- kind: frozen.kind,
978
- root: frozen.root,
1073
+ kind: patchRoot.kind,
1074
+ root: patchRoot.root,
979
1075
  agents,
980
- files: frozen.files.map(storedChangeFile),
981
- outside: (frozen.outside || []).map(storedChangeFile),
1076
+ counts: 'own',
1077
+ // A non-Git turn has no view-only side list: an edit it cannot replay
1078
+ // blocks its Undo like before.
1079
+ files: withOwnCounts(git ? files : [...files, ...outside], counted).map(storedChangeFile),
1080
+ outside: git ? withOwnCounts(outside, counted).map(storedChangeFile) : [],
982
1081
  });
983
1082
  // A fresh freeze measured the disk itself; nothing about it is unknown.
984
1083
  _unknownChangeStates.delete(pendingFreezeKey(ownerSessionId, checkpointId));
@@ -992,13 +1091,14 @@ const pendingFreezeKey = (ownerSessionId, checkpointId) => `${ownerSessionId}\0$
992
1091
  * baseline and freeze the change record. Single-flight per tracker; a seal
993
1092
  * requested while another runs waits for it and then seals again only if a
994
1093
  * completion wake reopened the turn meanwhile. `reseal` (a late worker) only
995
- * re-freezes a turn that is still sealed.
1094
+ * re-freezes a turn that is still sealed, after any seal in flight: that one
1095
+ * captured its agents before the worker was attributed.
996
1096
  */
997
1097
  function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
998
1098
  // Its history was deleted (clearSessionChangeHistory): never written again.
999
1099
  if (tracker.discarded) return Promise.resolve();
1000
1100
  if (tracker.freezing) {
1001
- if (reseal) return tracker.freezing;
1101
+ if (reseal) return tracker.freezing.then(() => sealTurn(ownerSessionId, tracker, { reseal: true }));
1002
1102
  return tracker.freezing.then(() => (tracker.sealed ? undefined : sealTurn(ownerSessionId, tracker)));
1003
1103
  }
1004
1104
  const epoch = tracker.sealEpoch;
@@ -1014,6 +1114,8 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1014
1114
  }
1015
1115
  }
1016
1116
  if (tracker.discarded || tracker.sealEpoch !== epoch || (reseal && !tracker.sealed)) return;
1117
+ // A re-seal never rewrites a turn once the next one opened.
1118
+ if (reseal && turnSuperseded(ownerSessionId, tracker)) return;
1017
1119
  tracker.sealed = true;
1018
1120
  await persistTurnSnapshotRecord(ownerSessionId, tracker);
1019
1121
  try {
@@ -1021,11 +1123,8 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1021
1123
  } catch {
1022
1124
  /* the change record adds Undo history; it never fails a turn */
1023
1125
  }
1024
- // Only a usable Git baseline can replace the exact apply_patch buffers. A
1025
- // contended worktree — and a worktree with no Git baseline at all — retains
1026
- // this session's bounded before/after content so Review can still undo its
1027
- // own files after the turn completes.
1028
- if (worktreeSnapshotUsable(tracker)) releaseDiffTrackerContent(tracker);
1126
+ // The bounded own before/after content stays until the next turn: a late
1127
+ // worker's re-seal and an Undo/Reapply of this turn fold into it.
1029
1128
  })().finally(() => {
1030
1129
  if (tracker.freezing === run) tracker.freezing = null;
1031
1130
  const count = (_pendingFreezes.get(key) || 1) - 1;
@@ -1037,7 +1136,9 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1037
1136
  }
1038
1137
 
1039
1138
  function resealTurn(ownerSessionId, tracker) {
1040
- if (!tracker.sealed || tracker.freezing || !clean(tracker.checkpointId) || tracker.resealQueued) return;
1139
+ // A turn still running seals with every published agent; one sealed or
1140
+ // sealing queues its re-seal (sealTurn runs it after a seal in flight).
1141
+ if (!(tracker.sealed || tracker.freezing) || !clean(tracker.checkpointId) || tracker.resealQueued) return;
1041
1142
  // On the session lock: an in-flight Undo/Reapply lands before the re-freeze
1042
1143
  // reads the worktree. A turn start queued ahead of it runs it before opening
1043
1144
  // (beginTurnSnapshot), so one queued re-seal covers every late worker.
@@ -1045,10 +1146,17 @@ function resealTurn(ownerSessionId, tracker) {
1045
1146
  void serializeChangeMutation(ownerSessionId, () => runQueuedReseal(ownerSessionId, tracker)).catch(() => {});
1046
1147
  }
1047
1148
 
1149
+ /** True once a newer turn of the session opened over `tracker`'s. */
1150
+ function turnSuperseded(ownerSessionId, tracker) {
1151
+ const turn = _turnsBySession.get(ownerSessionId);
1152
+ return Boolean(turn) && turn.generation !== tracker.ownerGeneration;
1153
+ }
1154
+
1048
1155
  /** Run `tracker`'s queued re-seal once, whichever lock step reaches it first. */
1049
1156
  function runQueuedReseal(ownerSessionId, tracker) {
1050
1157
  if (!tracker.resealQueued) return undefined;
1051
1158
  tracker.resealQueued = false;
1159
+ if (turnSuperseded(ownerSessionId, tracker)) return undefined;
1052
1160
  return sealTurn(ownerSessionId, tracker, { reseal: true });
1053
1161
  }
1054
1162
 
@@ -1065,92 +1173,124 @@ export async function completeTurnSnapshot(sessionId, expected = null) {
1065
1173
  return true;
1066
1174
  }
1067
1175
 
1068
- /**
1069
- * Can this turn's Git baseline own the review and its revert? A contended
1070
- * worktree cannot attribute concurrent mutations to one session, and a
1071
- * non-Git worktree never had a baseline to begin with. In both cases the
1072
- * exact apply_patch buffers are the only safe revert source.
1073
- */
1176
+ /** Is this turn's review labelled with its uncontended Git baseline? (The
1177
+ * review itself is always the tracker's own segments.) */
1074
1178
  function worktreeSnapshotUsable(tracker) {
1075
1179
  return Boolean(tracker && !tracker.worktreeContended && tracker.worktreeSnapshot);
1076
1180
  }
1077
1181
 
1078
- /** Match a user-supplied file against a recorded session-owned path. */
1079
- function recordedPathMatchesFile(owned, root, file) {
1080
- const target = clean(file);
1081
- if (!target) return false;
1082
- const normalizedRoot = pathKey(root);
1083
- const normalizedTarget = pathKey(target);
1084
- const relativeTarget =
1085
- normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`)
1086
- ? normalizedTarget.slice(normalizedRoot.length + 1)
1087
- : normalizedTarget;
1088
- const normalizedOwned = pathKey(owned);
1089
- return normalizedOwned === relativeTarget || normalizedOwned === normalizedTarget;
1090
- }
1091
-
1092
- /** Re-open the recorded baseline for a session whose tracker is gone. Returns
1093
- * null when the record is missing or its tree object has been collected. */
1094
- async function resumeRecordedSnapshot(ownerSessionId, { omitPatch = false } = {}) {
1095
- if (!ownerSessionId) return null;
1096
- let record = null;
1097
- try {
1098
- record = await loadTurnSnapshotRecord(ownerSessionId);
1099
- } catch {
1100
- return null;
1101
- }
1182
+ /** The latest turn's review once its tracker is gone (the next turn replaced
1183
+ * it, the cache evicted it, the runtime restarted): its change record's
1184
+ * files still applied, with their own counts and recorded patches — never a
1185
+ * worktree diff, so a foreign edit inside an own file stays out. */
1186
+ async function recordedTurnReview(ownerSessionId, { omitPatch }) {
1187
+ const record = await latestTurnRecord(ownerSessionId);
1102
1188
  if (!record) return null;
1103
- const snapshot = await resumeTurnWorktreeSnapshot(record.root, record.baselineTree, {
1104
- paths: record.toolFiles,
1105
- baselineFiles: record.baselineFiles,
1106
- omitPatch,
1107
- }).catch(() => null);
1108
- if (!snapshot) return null;
1109
- return { record, snapshot };
1110
- }
1111
-
1112
- /** A review rebuilt from the durable record. Same shape as the live one, but
1113
- * scoped to the recording session's own paths so a worktree shared with other
1114
- * sessions stays attributable instead of losing its revert entirely. */
1115
- async function scopedReviewFromRecord(ownerSessionId, { omitPatch = false } = {}) {
1116
- const resumed = await resumeRecordedSnapshot(ownerSessionId, { omitPatch });
1117
- if (!resumed) return null;
1118
- void saveTurnReviewSummary(ownerSessionId, resumed.record, resumed.snapshot);
1189
+ const files = (record.files || []).filter((file) => fileState(file) === 'applied');
1190
+ const patches = files.map(changeDisplayPatch);
1191
+ const rows = files.map((file, index) => {
1192
+ const row = {
1193
+ path: file.path,
1194
+ ...(file.oldPath ? { oldPath: file.oldPath } : {}),
1195
+ status: file.status,
1196
+ additions: Number(file.additions) || 0,
1197
+ deletions: Number(file.deletions) || 0,
1198
+ ...(file.binary ? { binary: true } : {}),
1199
+ };
1200
+ // The record dropped its patch for size: still listed too large.
1201
+ return recordPatchOmitted(file, patches[index]) ? patchOmittedFile(row) : row;
1202
+ });
1203
+ const shaped = omitPatch ? { files: rows, patch: '' } : perFileReview(rows, patches.join(''));
1119
1204
  return {
1120
1205
  supported: true,
1121
- files: resumed.snapshot.files || [],
1122
- patch: resumed.snapshot.patch || '',
1123
- ...(resumed.snapshot.patchOmitted ? { patchOmitted: true } : {}),
1206
+ files: shaped.files,
1207
+ patch: shaped.patch,
1208
+ ...(omitPatch ? { patchOmitted: true } : {}),
1209
+ ...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
1124
1210
  snapshotKind: 'scoped',
1125
1211
  revertMode: 'scoped',
1126
- checkpointId: clean(resumed.record.checkpointId),
1127
- patchTruncated: resumed.snapshot.patchTruncated === true,
1212
+ checkpointId: clean(record.checkpointId),
1213
+ patchTruncated: false,
1128
1214
  authoritative: true,
1129
1215
  agents: publicAgentReviews(ownerSessionId),
1130
1216
  reason: 'recorded',
1131
1217
  };
1132
1218
  }
1133
1219
 
1134
- /** The files and counts last computed from the durable record, without
1135
- * rescanning the worktree. `cached` (not `recorded`): the caller's
1136
- * refreshing read still follows and replaces it. */
1137
- async function cachedReviewFromRecord(ownerSessionId) {
1138
- const record = await loadTurnSnapshotRecord(ownerSessionId).catch(() => null);
1139
- const cached = record ? await loadTurnReviewSummary(ownerSessionId, record) : null;
1140
- if (!cached) return null;
1141
- return {
1142
- supported: true,
1143
- files: cached.files,
1144
- patch: '',
1145
- patchOmitted: true,
1146
- snapshotKind: 'scoped',
1147
- revertMode: 'scoped',
1148
- checkpointId: clean(record.checkpointId),
1149
- patchTruncated: cached.patchTruncated === true,
1150
- authoritative: true,
1151
- agents: publicAgentReviews(ownerSessionId),
1152
- reason: 'cached',
1153
- };
1220
+ /** A record file kept without its patch for size. */
1221
+ const recordPatchOmitted = (file, patch) => file?.patchOmitted === true && !patch;
1222
+
1223
+ /** A review reply's files and patch under the per-file caps: a file whose
1224
+ * sections were cut for display, or pass the per-file cap or the total left,
1225
+ * is listed with its counts but without its patch (read on its own when it
1226
+ * is opened). `diffTooLarge` once nothing fit. */
1227
+ function perFileReview(files, patch) {
1228
+ const sections = new Map();
1229
+ for (const section of splitPatchSections(Buffer.from(patch || '', 'utf8'))) {
1230
+ const key = pathKey(section.newPath ?? section.oldPath ?? '');
1231
+ const entry = sections.get(key) || { parts: [], bytes: 0, cut: false };
1232
+ entry.parts.push(section.patch);
1233
+ entry.bytes += section.patch.length;
1234
+ entry.cut ||= reviewSectionTruncated(section.patch);
1235
+ sections.set(key, entry);
1236
+ }
1237
+ const budget = createReviewPatchBudget();
1238
+ const omitted = new Set();
1239
+ const kept = [];
1240
+ for (const [key, entry] of sections) {
1241
+ if (!entry.cut && budget.admit(entry.bytes)) kept.push(...entry.parts);
1242
+ else omitted.add(key);
1243
+ }
1244
+ const shaped = files.map((file) => (omitted.has(pathKey(file.path)) ? patchOmittedFile(file) : file));
1245
+ const text = Buffer.concat(kept).toString('utf8');
1246
+ return { files: shaped, patch: text, diffTooLarge: reviewDiffTooLarge(shaped, text) };
1247
+ }
1248
+
1249
+ /** The latest turn's change record, its unrecordable file states applied. */
1250
+ async function latestTurnRecord(ownerSessionId) {
1251
+ const checkpointId = await latestRecordedCheckpoint(ownerSessionId);
1252
+ const stored = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId).catch(() => null) : null;
1253
+ return withUnknownChangeStates(ownerSessionId, stored);
1254
+ }
1255
+
1256
+ /** One file's whole display patch in the tracker's view, rendered on its own
1257
+ * instead of within the reply's caps; '' when the view lists no such file. */
1258
+ function trackerFilePatch(tracker, path) {
1259
+ const { root, kind } = trackerPatchRoot(tracker);
1260
+ const key = pathKey(path);
1261
+ const budget = createLineDiffBudget();
1262
+ for (const chain of tracker.chains.values()) {
1263
+ const edit = chainEdit(chain, root);
1264
+ if (!edit || (kind === 'git' && !edit.inside) || pathKey(viewPath(root, edit.end)) !== key) continue;
1265
+ return edit.steps.map((step) => renderStep(step, root, budget).text).join('');
1266
+ }
1267
+ return '';
1268
+ }
1269
+
1270
+ /** One file of the latest turn's review with its whole patch, up to the
1271
+ * file-patch read's cap: the file a reply listed `patchOmitted`. */
1272
+ export async function getTurnReviewFilePatch(_worktree, sessionId, file) {
1273
+ const path = clean(file).replace(/\\/g, '/');
1274
+ if (DISABLED) return { supported: false, path, patch: '' };
1275
+ const ownerSessionId = clean(sessionId);
1276
+ if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
1277
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
1278
+ if (tracker) return filePatchReply(path, trackerFilePatch(tracker, path));
1279
+ // Only a file the recorded review lists, by its own path.
1280
+ const record = await latestTurnRecord(ownerSessionId);
1281
+ const match = record ? findChangeFile(record, path) : null;
1282
+ if (!match || fileState(match) !== 'applied' || changePathKey(match.path) !== changePathKey(relativeRecordPath(record, path))) {
1283
+ return filePatchReply(path, '');
1284
+ }
1285
+ const patch = changeDisplayPatch(match);
1286
+ if (recordPatchOmitted(match, patch)) return { ...filePatchReply(path, ''), ...patchOmittedFile({}) };
1287
+ return filePatchReply(path, patch);
1288
+ }
1289
+
1290
+ /** `value` as findChangeFile reads it: relative to the record's root. */
1291
+ function relativeRecordPath(record, value) {
1292
+ const target = clean(value).replace(/\\/g, '/');
1293
+ return isAbsolute(target) && record?.root ? relative(record.root, resolve(target)).replace(/\\/g, '/') : target;
1154
1294
  }
1155
1295
 
1156
1296
  function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
@@ -1163,69 +1303,38 @@ function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
1163
1303
  }
1164
1304
  }
1165
1305
 
1166
- /** Restore recorded paths — every one of them, or just the file the user picked. */
1167
- async function revertFromRecord(ownerSessionId, file, expectedCheckpointId) {
1168
- const resumed = await resumeRecordedSnapshot(ownerSessionId);
1169
- if (!resumed) return null;
1170
- assertCheckpointMatches(expectedCheckpointId, resumed.record.checkpointId);
1171
- const owned = resumed.record.toolFiles;
1172
- const targets = file ? owned.filter((path) => recordedPathMatchesFile(path, resumed.record.root, file)) : owned;
1173
- if (targets.length === 0) {
1174
- throw new Error('turn review revert cannot attribute this file to the session');
1175
- }
1176
- const paths = await revertTurnWorktreePaths(resumed.snapshot, targets);
1177
- return { checkpointId: clean(resumed.record.checkpointId), paths };
1178
- }
1179
-
1180
- /** Return the authoritative Lead net diff plus attributed child reviews. */
1306
+ /** Return the Lead turn's own-file diff plus attributed child reviews, and
1307
+ * `historyTurns`: the retained turn records clearSessionChangeHistory deletes. */
1181
1308
  export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
1182
- if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
1309
+ if (DISABLED) return { supported: false, files: [], patch: '', agents: [], historyTurns: 0 };
1183
1310
  const ownerSessionId = clean(sessionId);
1311
+ const review = await readTurnReview(ownerSessionId, options);
1312
+ const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
1313
+ return { ...review, historyTurns: records.length };
1314
+ }
1315
+
1316
+ async function readTurnReview(ownerSessionId, options) {
1184
1317
  const turn = _turnsBySession.get(ownerSessionId);
1185
1318
  const tracker = _diffTrackersBySession.get(ownerSessionId);
1186
- const isolatedWorktreeSnapshot = worktreeSnapshotUsable(tracker) ? tracker.worktreeSnapshot : null;
1187
- if (isolatedWorktreeSnapshot && !tracker.sealed && options.refresh !== false) {
1188
- // Only the review bar's interval timer sends `poll: 'background'`; an
1189
- // open review (asking for its patch, no `summary`) keeps a short gap.
1190
- // Every other read (opening the review, a manual or tool-boundary
1191
- // refresh, a client that sends no `poll`) captures the worktree now.
1192
- const refreshOptions = options.poll === 'background' ? { visible: options.summary !== true } : { force: true };
1193
- try {
1194
- await refreshTurnWorktreeSnapshot(isolatedWorktreeSnapshot, refreshOptions);
1195
- } catch {
1196
- // Best-effort refresh: the review reads the last captured state.
1197
- }
1198
- }
1199
- const snapshot = isolatedWorktreeSnapshot;
1200
- // Without a usable baseline the review falls back to this session's own
1201
- // mutations — including a worktree that has no Git repository at all, which
1202
- // used to leave the bar with no revert mode whatsoever.
1203
- const trackedRevertAvailable = !snapshot && tracker?.valid === true && trackedPairs(tracker).length > 0;
1204
- if (!snapshot && !trackedRevertAvailable) {
1205
- // Both in-memory sources are gone: the next turn replaced the tracker, the
1206
- // turn cache evicted it, or the runtime restarted. The recorded baseline is
1207
- // still on disk and still exact. Rebuilt per read and then discarded, so a
1208
- // summary read skips its patch text instead of dropping it afterwards.
1209
- // A collapsed read that asked for no refresh (entering the session) takes
1210
- // the last computed summary instead of queueing a rescan.
1211
- if (options.refresh === false && options.summary === true) {
1212
- const cached = await cachedReviewFromRecord(ownerSessionId);
1213
- if (cached) return cached;
1214
- }
1215
- const recorded = await scopedReviewFromRecord(ownerSessionId, { omitPatch: options.summary === true });
1319
+ if (!tracker) {
1320
+ const recorded = await recordedTurnReview(ownerSessionId, { omitPatch: options.summary === true });
1216
1321
  if (recorded) return recorded;
1217
1322
  }
1323
+ const view = tracker ? trackerView(tracker) : null;
1324
+ const worktree = worktreeSnapshotUsable(tracker);
1218
1325
  let revertMode = '';
1219
- if (snapshot) revertMode = 'worktree';
1220
- else if (trackedRevertAvailable) revertMode = 'tracked';
1326
+ if (worktree) revertMode = 'worktree';
1327
+ else if (tracker?.valid && view.files.length) revertMode = 'tracked';
1328
+ const shaped = perFileReview(view?.files || [], view?.patch || '');
1221
1329
  return {
1222
1330
  supported: true,
1223
- files: snapshot?.files || [],
1224
- patch: snapshot?.patch ?? tracker?.unifiedDiff ?? '',
1225
- snapshotKind: snapshot ? 'worktree' : 'tool',
1331
+ files: shaped.files,
1332
+ patch: shaped.patch,
1333
+ ...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
1334
+ snapshotKind: worktree ? 'worktree' : 'tool',
1226
1335
  revertMode,
1227
1336
  checkpointId: clean(turn?.checkpointId || tracker?.checkpointId),
1228
- patchTruncated: (snapshot || tracker)?.patchTruncated === true,
1337
+ patchTruncated: false,
1229
1338
  authoritative: Boolean(turn && tracker),
1230
1339
  agents: publicAgentReviews(ownerSessionId),
1231
1340
  ...(turn ? { generation: turn.generation } : { reason: 'no-turn' }),
@@ -1259,16 +1368,83 @@ export async function getSessionReviewDiff(worktree, sessionId) {
1259
1368
  // session recorded before baselines existed) come from them.
1260
1369
  loadTurnChangeRecords(ownerSessionId).catch(() => []),
1261
1370
  ]);
1262
- const merged = await sessionChangeReview(review, { baselines, records });
1263
- // `historyTurns`: the retained turn records clearSessionChangeHistory deletes.
1264
- return { ...merged, historyTurns: records.length };
1371
+ return await sessionChangeReview(review, { baselines, records });
1265
1372
  }
1266
1373
 
1267
- async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1374
+ /** What the session review diffs inside a Git root: the stored scope (or
1375
+ * live tracker) of this worktree, the paths the session owns there, and its
1376
+ * earliest baseline tree with the recorded first states that complete it. */
1377
+ async function sessionReviewScope(worktree, ownerSessionId) {
1268
1378
  const tracker = _diffTrackersBySession.get(ownerSessionId);
1269
1379
  const scopes = await loadSessionSnapshotRecords(ownerSessionId).catch(() => []);
1270
1380
  const stored = scopes.find((scope) => scopeMatchesWorktree(scope, worktree, tracker));
1271
1381
  const root = stored?.root || tracker?.worktreeSnapshot?.root || clean(worktree);
1382
+ const ownedPaths = new Set(stored?.toolFiles || []);
1383
+ for (const path of ownedPathsUnder(root, tracker?.ownedPaths)) ownedPaths.add(path);
1384
+ const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
1385
+ const baselineFiles = new Map((stored?.baselineFiles || []).map((entry) => [pathKey(entry.path), entry]));
1386
+ const storedPaths = new Set((stored?.toolFiles || []).map(pathKey));
1387
+ for (const entry of tracker?.worktreeSnapshot?.baselineFiles.values() || []) {
1388
+ if (!storedPaths.has(pathKey(entry.path))) baselineFiles.set(pathKey(entry.path), entry);
1389
+ }
1390
+ return { tracker, stored, root, ownedPaths, baselineTree, baselineFiles: [...baselineFiles.values()] };
1391
+ }
1392
+
1393
+ /** `value` (absolute, or relative to `root`) as a path relative to `root`;
1394
+ * null outside it. */
1395
+ function relativeInside(root, value) {
1396
+ const target = clean(value);
1397
+ if (!root || !target) return null;
1398
+ const rel = relative(resolve(root), isAbsolute(target) ? resolve(target) : resolve(root, target));
1399
+ if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return null;
1400
+ return rel.replace(/\\/g, '/');
1401
+ }
1402
+
1403
+ /** One file of the session review with its whole patch, up to the file-patch
1404
+ * read's cap: the file a reply listed `patchOmitted`. `file` is the listed
1405
+ * file's `id` or path, `oldPath` a rename's source. Inside the Git root it is
1406
+ * the session's baseline tree against the worktree now for that path alone;
1407
+ * elsewhere its first-seen state measured against the disk now. */
1408
+ export async function getSessionReviewFilePatch(worktree, sessionId, file, oldPath = '') {
1409
+ const path = clean(file).replace(/\\/g, '/');
1410
+ if (DISABLED) return { supported: false, path, patch: '' };
1411
+ const ownerSessionId = clean(sessionId);
1412
+ if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
1413
+ const scope = await sessionReviewScope(worktree, ownerSessionId);
1414
+ const rel = scope.baselineTree ? relativeInside(scope.root, path) : null;
1415
+ if (rel !== null) {
1416
+ // Each target is authorized on its own: an unowned file is never read,
1417
+ // an unowned rename source never joins its diff.
1418
+ const owned = new Set([...scope.ownedPaths].map(pathKey));
1419
+ if (!owned.has(pathKey(rel))) return { supported: true, path, patch: '' };
1420
+ const source = relativeInside(scope.root, oldPath) || '';
1421
+ const oldRel = source && owned.has(pathKey(source)) ? source : '';
1422
+ const result = await resumeTurnWorktreeFilePatch(scope.root, scope.baselineTree, {
1423
+ paths: [...scope.ownedPaths],
1424
+ baselineFiles: scope.baselineFiles,
1425
+ path: rel,
1426
+ oldPath: oldRel,
1427
+ maxBytes: REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
1428
+ }).catch(() => null);
1429
+ if (result) return { supported: true, path, ...result };
1430
+ }
1431
+ const [base, baselines, records] = await Promise.all([
1432
+ // Beside a Git baseline a file outside its root is measured from its
1433
+ // first state; the Git side's own files need no diff for that.
1434
+ rel === null && scope.baselineTree && scope.root
1435
+ ? { supported: true, root: scope.root, files: [], patch: '', source: 'git' }
1436
+ : baselineSessionReviewDiff(worktree, ownerSessionId),
1437
+ loadSessionBaselines(ownerSessionId).catch(() => null),
1438
+ loadTurnChangeRecords(ownerSessionId).catch(() => []),
1439
+ ]);
1440
+ return await sessionChangeReview(base, { baselines, records, file: path });
1441
+ }
1442
+
1443
+ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1444
+ const { tracker, stored, root, ownedPaths, baselineTree, baselineFiles } = await sessionReviewScope(
1445
+ worktree,
1446
+ ownerSessionId
1447
+ );
1272
1448
  // A running turn is still mutating the worktree: shell, office and script
1273
1449
  // edits only reach the checkpoint diff once it is refreshed, exactly as the
1274
1450
  // turn review bar does.
@@ -1279,8 +1455,6 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1279
1455
  // Best-effort refresh: the checkpoint diff uses the last captured state.
1280
1456
  }
1281
1457
  }
1282
- const ownedPaths = new Set(stored?.toolFiles || []);
1283
- for (const path of sessionAttributedPaths(root, tracker)) ownedPaths.add(path);
1284
1458
  if (ownedPaths.size === 0) {
1285
1459
  // Authoritatively empty only when the session left ownership metadata
1286
1460
  // behind (a stored scope, a live tracker, or a deleted history); with
@@ -1296,21 +1470,12 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1296
1470
  source: known ? 'empty' : 'absent',
1297
1471
  };
1298
1472
  }
1299
- const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
1300
1473
  if (root && baselineTree) {
1301
- const baselineFiles = new Map((stored?.baselineFiles || []).map((entry) => [pathKey(entry.path), entry]));
1302
- const storedPaths = new Set((stored?.toolFiles || []).map(pathKey));
1303
- for (const entry of tracker?.worktreeSnapshot?.baselineFiles.values() || []) {
1304
- if (!storedPaths.has(pathKey(entry.path))) baselineFiles.set(pathKey(entry.path), entry);
1305
- }
1306
1474
  const snapshot = await resumeTurnWorktreeSnapshot(root, baselineTree, {
1307
1475
  paths: [...ownedPaths],
1308
- baselineFiles: [...baselineFiles.values()],
1476
+ baselineFiles,
1309
1477
  }).catch(() => null);
1310
1478
  if (snapshot) {
1311
- // The island counts only what is still uncommitted: the owned files
1312
- // that differ from HEAD now, measured against HEAD.
1313
- const uncommittedFiles = await diffTurnWorktreeHead(snapshot, [...ownedPaths]).catch(() => null);
1314
1479
  return {
1315
1480
  supported: true,
1316
1481
  root,
@@ -1320,7 +1485,6 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1320
1485
  snapshotKind: 'session',
1321
1486
  authoritative: true,
1322
1487
  source: 'git',
1323
- ...(uncommittedFiles ? { uncommittedFiles } : {}),
1324
1488
  };
1325
1489
  }
1326
1490
  }
@@ -1333,8 +1497,7 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1333
1497
  supported: true,
1334
1498
  root,
1335
1499
  files: [],
1336
- patch: tracker.unifiedDiff || '',
1337
- patchTruncated: tracker.patchTruncated === true,
1500
+ patch: '',
1338
1501
  snapshotKind: 'session-tool',
1339
1502
  authoritative: true,
1340
1503
  source: 'tool',
@@ -1354,52 +1517,105 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1354
1517
  };
1355
1518
  }
1356
1519
 
1357
- /** Why this turn cannot revert through its exact apply_patch buffers. */
1358
- function unavailableTrackedRevertReason(tracker) {
1359
- return tracker.worktreeContended
1360
- ? 'turn review revert is unavailable while sessions share a worktree'
1361
- : 'turn review tracked file snapshot is unavailable';
1362
- }
1520
+ const UNATTRIBUTED_FILE = 'turn review revert cannot attribute this file to the session';
1363
1521
 
1364
- /** Restore the latest turn — every file it can attribute (`whole`) or the one
1365
- * the user picked — to its turn-start state. Resolves to the restored turn's
1366
- * checkpoint and the absolute paths actually restored: a contended worktree
1367
- * restores only the Lead's own tracked files, never its workers'. */
1368
- async function revertLatestTurnUnlocked(worktree, ownerSessionId, file, whole, expectedCheckpointId) {
1522
+ /** Reverse-apply the latest turn's own changes — every file its own tools
1523
+ * wrote (`file` null) or the own file the user picked. A sealed turn undoes
1524
+ * its change record (state included); a running one its tracker's own
1525
+ * segments. Resolves to the turn's checkpoint, the absolute paths touched and
1526
+ * whether the record already carries the new state. */
1527
+ async function revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId) {
1369
1528
  const tracker = _diffTrackersBySession.get(ownerSessionId);
1370
1529
  if (tracker) assertCheckpointMatches(expectedCheckpointId, tracker.checkpointId);
1371
- const restored = (paths) => ({ checkpointId: clean(tracker.checkpointId), paths });
1372
- const recordFile = whole ? null : file;
1373
- if (tracker && !worktreeSnapshotUsable(tracker)) {
1374
- const pairs = trackedPairs(tracker);
1375
- if (pairs.length > 0) {
1376
- let selected = pairs;
1377
- if (!whole) {
1378
- const pair = pairs.find((entry) => pairMatchesFile(entry, worktree, file));
1379
- if (!pair) throw new Error('turn review tracked file snapshot is unavailable');
1380
- selected = [pair];
1381
- }
1382
- return restored(await revertTrackedPairs(tracker, worktree, selected));
1383
- }
1384
- const recorded = await revertFromRecord(ownerSessionId, recordFile, expectedCheckpointId);
1385
- if (recorded) return recorded;
1386
- throw new Error(unavailableTrackedRevertReason(tracker));
1530
+ if (!tracker || tracker.sealed) {
1531
+ const checkpointId = clean(tracker?.checkpointId) || (await latestRecordedCheckpoint(ownerSessionId));
1532
+ if (!tracker) assertCheckpointMatches(expectedCheckpointId, checkpointId);
1533
+ const record = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId) : null;
1534
+ if (record) return await revertRecordedTurn(ownerSessionId, withUnknownChangeStates(ownerSessionId, record), file);
1535
+ if (!tracker) throw new Error('turn worktree snapshot is unavailable');
1536
+ }
1537
+ const paths = await revertTrackedTurn(tracker, worktree, file);
1538
+ return { checkpointId: clean(tracker.checkpointId), paths, recorded: false };
1539
+ }
1540
+
1541
+ /** The latest turn on record: the stored Git review's, else the newest change record's. */
1542
+ async function latestRecordedCheckpoint(ownerSessionId) {
1543
+ const snapshot = await loadTurnSnapshotRecord(ownerSessionId).catch(() => null);
1544
+ if (clean(snapshot?.checkpointId)) return clean(snapshot.checkpointId);
1545
+ const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
1546
+ return clean(records.at(-1)?.checkpointId);
1547
+ }
1548
+
1549
+ const recordFilePaths = (root, files) =>
1550
+ files.flatMap((entry) => [entry.path, entry.oldPath]).filter(Boolean).map((path) => resolve(root, path));
1551
+
1552
+ /** Undo a sealed turn's applied record files (all, or the one named). */
1553
+ async function revertRecordedTurn(ownerSessionId, record, file) {
1554
+ let targets;
1555
+ if (file) {
1556
+ const match = findChangeFile(record, file);
1557
+ if (!match) throw new Error(UNATTRIBUTED_FILE);
1558
+ if (fileState(match) !== 'applied') throw new Error(`${match.path} is already undone`);
1559
+ targets = [match];
1560
+ } else {
1561
+ targets = (record.files || []).filter((entry) => fileState(entry) === 'applied');
1387
1562
  }
1388
- if (tracker?.worktreeSnapshot) {
1389
- return restored(
1390
- whole
1391
- ? await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot)
1392
- : await revertTurnWorktreeFile(tracker.worktreeSnapshot, file)
1563
+ if (targets.length > 0) await mutateRecordFiles(ownerSessionId, record, targets, true);
1564
+ return { checkpointId: clean(record.checkpointId), paths: recordFilePaths(record.root, targets), recorded: true };
1565
+ }
1566
+
1567
+ /** Undo a running turn's own segments (every file, or the one matching
1568
+ * `file`), newest first. */
1569
+ async function revertTrackedTurn(tracker, worktree, file) {
1570
+ if (!tracker.valid) throw new Error('turn review tracked file snapshot is unavailable');
1571
+ const patchRoot = trackerPatchRoot(tracker, resolve(clean(worktree) || '.'));
1572
+ const git = patchRoot.kind === 'git';
1573
+ let targets = [...tracker.chains.values()]
1574
+ .map((chain) => ({ chain, edit: chainEdit(chain, patchRoot.root) }))
1575
+ // Beside a Git root, an edit outside it is view-only, never undone.
1576
+ .filter(({ edit }) => edit && (!git || edit.inside));
1577
+ if (file) {
1578
+ const target = targets.find(({ chain }) => chainMatchesPath(chain, patchRoot.root, file));
1579
+ if (!target) throw new Error(UNATTRIBUTED_FILE);
1580
+ targets = [target];
1581
+ }
1582
+ if (targets.length === 0) return [];
1583
+ for (const { edit } of targets) {
1584
+ const omitted = edit.steps.flatMap(({ before, after }) => [before.content, after.content]).find(isOmittedContent);
1585
+ if (!omitted) continue;
1586
+ throw new Error(
1587
+ `turn review revert is unavailable for ${edit.start.displayPath}: binary/large file content was not captured (${omitted.size} bytes)`
1393
1588
  );
1394
1589
  }
1395
- const recorded = await revertFromRecord(ownerSessionId, recordFile, expectedCheckpointId);
1396
- if (recorded) return recorded;
1397
- throw new Error('turn worktree snapshot is unavailable');
1590
+ const edits = targets.map(({ edit }) => edit);
1591
+ const objectFormat = await editsObjectFormat(edits, patchRoot);
1592
+ const budget = createLineDiffBudget();
1593
+ const files = edits.map((edit) => storedChangeFile(editChangeFile(edit, patchRoot.root, objectFormat, budget)));
1594
+ const blocked = files.find((entry) => entry.revertable !== true);
1595
+ if (blocked) throw new Error(`turn review revert is unavailable for ${blocked.path}: ${blocked.reason}`);
1596
+ await applyTurnChangeFiles({ root: patchRoot.root, kind: patchRoot.kind, counts: 'own' }, files, {
1597
+ reverse: true,
1598
+ });
1599
+ undoTrackedChains(
1600
+ tracker,
1601
+ targets.map(({ chain }) => chain)
1602
+ );
1603
+ const paths = recordFilePaths(patchRoot.root, files);
1604
+ noteTurnWorktreeChange(paths);
1605
+ if (tracker.worktreeSnapshot) {
1606
+ try {
1607
+ await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
1608
+ } catch {
1609
+ // Best-effort refresh: the next review read captures afresh.
1610
+ }
1611
+ }
1612
+ return paths;
1398
1613
  }
1399
1614
 
1400
- /** Record a composer restore in the turn's change record: a record file whose
1401
- * every side (a rename's old path too) was restored is back at its
1402
- * turn-start bytes, i.e. `reverted`; every other file keeps its state. */
1615
+ /** Record a running turn's composer Undo in the record an earlier seal of the
1616
+ * same turn left (a completion wake reopened it): a record file whose every
1617
+ * side (a rename's old path too) was undone is `reverted`; every other file
1618
+ * keeps its state. */
1403
1619
  async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
1404
1620
  if (!ownerSessionId || !checkpointId) return;
1405
1621
  const record = await loadTurnChangeRecord(ownerSessionId, checkpointId);
@@ -1425,26 +1641,28 @@ async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
1425
1641
  /** Composer Undo of the latest turn, on the per-session change lock shared
1426
1642
  * with historical Undo/Reapply, recorded in the turn's change record so the
1427
1643
  * historical view offers Reapply rather than a second Undo. */
1428
- function revertLatestTurn(worktree, sessionId, file, whole, expectedCheckpointId) {
1644
+ function revertLatestTurn(worktree, sessionId, file, expectedCheckpointId) {
1429
1645
  const ownerSessionId = clean(sessionId);
1430
1646
  return serializeChangeMutation(ownerSessionId, async () => {
1431
- // A seal still freezing would record the pre-restore disk after this.
1647
+ // A seal still freezing would record the pre-restore state after this.
1432
1648
  const freezing = _diffTrackersBySession.get(ownerSessionId)?.freezing;
1433
1649
  if (freezing) await freezing.catch(() => {});
1434
- const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file, whole, expectedCheckpointId);
1435
- await recordComposerRevert(ownerSessionId, restored);
1650
+ const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId);
1651
+ if (!restored.recorded) await recordComposerRevert(ownerSessionId, restored);
1436
1652
  return await getTurnReviewDiff(worktree, ownerSessionId);
1437
1653
  });
1438
1654
  }
1439
1655
 
1440
- /** Restore one reviewed file to this turn's worktree baseline, never to HEAD. */
1656
+ /** Undo this turn's own changes to one reviewed file (never a restore to HEAD
1657
+ * or to the turn-start bytes: foreign edits outside its hunks stay). */
1441
1658
  export function revertTurnReviewFile(worktree, sessionId, file, expectedCheckpointId = '') {
1442
- return revertLatestTurn(worktree, sessionId, file, false, expectedCheckpointId);
1659
+ if (!clean(file)) return Promise.reject(new TypeError('turn review file path is required'));
1660
+ return revertLatestTurn(worktree, sessionId, file, expectedCheckpointId);
1443
1661
  }
1444
1662
 
1445
- /** Restore every file in the reviewed turn to its turn-start state. */
1663
+ /** Undo this turn's own changes to every file its tools wrote. */
1446
1664
  export function revertTurnReview(worktree, sessionId, expectedCheckpointId = '') {
1447
- return revertLatestTurn(worktree, sessionId, null, true, expectedCheckpointId);
1665
+ return revertLatestTurn(worktree, sessionId, null, expectedCheckpointId);
1448
1666
  }
1449
1667
 
1450
1668
  // ── Historical turn changes (per-turn Undo/Reapply) ─────────────────────────
@@ -1483,9 +1701,10 @@ export async function getTurnChanges(sessionId, options = {}) {
1483
1701
  // An old record's outside-root file kept no counts: measure it so the card
1484
1702
  // agrees with the session review and the island.
1485
1703
  if (record?.files?.length) {
1704
+ const budget = createLineDiffBudget();
1486
1705
  record = {
1487
1706
  ...record,
1488
- files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root))),
1707
+ files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root, budget))),
1489
1708
  };
1490
1709
  }
1491
1710
  let status = 'missing';
@@ -1553,88 +1772,68 @@ function withUnknownChangeStates(ownerSessionId, record) {
1553
1772
  return keys ? { ...record, files: markUnknownStates(record.files, keys) } : record;
1554
1773
  }
1555
1774
 
1556
- /** The exact-buffer review tracker (non-Git or contended worktree) an
1557
- * Undo/Reapply must fold into, with the pre-mutation bytes of every moved
1558
- * file under the tracker's root. Null when the review has a usable Git
1559
- * baseline (it re-captures instead) or nothing to fold. */
1560
- async function trackedReviewFold(ownerSessionId, root, targets, reverse) {
1775
+ /** Bring the latest-turn review (composer bar, session Diff) in line with an
1776
+ * Undo/Reapply that just landed: signal the moved paths, fold the record's
1777
+ * own chains out of (or back into) the turn's tracker when it is that turn,
1778
+ * re-capture the Git snapshot (sealed ones too) and persist its
1779
+ * attribution. Best-effort: the mutation itself already landed. */
1780
+ async function refreshLatestTurnReview(ownerSessionId, record, targets, reverse) {
1781
+ noteTurnWorktreeChange(recordFilePaths(record.root, targets));
1561
1782
  const tracker = _diffTrackersBySession.get(ownerSessionId);
1562
- if (!tracker?.valid || worktreeSnapshotUsable(tracker) || !tracker.worktreeRoot) return null;
1563
- const inside = (path) => {
1564
- const rel = relative(tracker.worktreeRoot, path);
1565
- return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
1566
- };
1567
- const moves = [];
1568
- for (const entry of targets) {
1569
- const path = resolve(root, entry.path);
1570
- const oldPath = entry.oldPath ? resolve(root, entry.oldPath) : path;
1571
- const move = reverse ? { source: path, destination: oldPath } : { source: oldPath, destination: path };
1572
- if (inside(move.source) && inside(move.destination)) moves.push(move);
1783
+ if (!tracker) return;
1784
+ if (tracker.valid && clean(tracker.checkpointId) === clean(record.checkpointId)) {
1785
+ const root = resolve(record.root);
1786
+ if (reverse) {
1787
+ const matches = (chain) =>
1788
+ targets.some((file) => [file.path, file.oldPath].some((path) => chainMatchesPath(chain, root, path)));
1789
+ undoTrackedChains(tracker, [...tracker.chains.values()].filter(matches));
1790
+ } else {
1791
+ redoTrackedChains(tracker, root, targets);
1792
+ }
1573
1793
  }
1574
- if (moves.length === 0) return null;
1794
+ if (!tracker.worktreeSnapshot) return;
1575
1795
  try {
1576
- for (const move of moves) move.before = await currentTrackedContent(move.source);
1796
+ await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
1577
1797
  } catch {
1578
- return { tracker, moves, unreadable: true };
1798
+ // Best-effort refresh: persist the last captured state.
1579
1799
  }
1580
- return { tracker, moves };
1581
- }
1582
-
1583
- /** One moved file as a tracked change: its pre-mutation bytes and what the
1584
- * mutation left at its destination. */
1585
- async function movedFileChange(root, move) {
1586
- const change = {
1587
- path: move.source,
1588
- displayPath: trackedRelativePath(root, { path: move.source }),
1589
- before: move.before,
1590
- after: await currentTrackedContent(move.destination),
1591
- };
1592
- if (move.destination === move.source) return change;
1593
- return {
1594
- ...change,
1595
- newPath: move.destination,
1596
- newDisplayPath: trackedRelativePath(root, { path: move.destination }),
1597
- };
1800
+ if (tracker.sealed) await persistTurnSnapshotRecord(ownerSessionId, tracker);
1598
1801
  }
1599
1802
 
1600
- /** Fold an applied Undo/Reapply into the exact tracker. A file the tracker
1601
- * cannot read back exactly drops the tracked review rather than leaving it
1602
- * describing the old disk. */
1603
- async function foldReviewMutation({ tracker, moves, unreadable }) {
1604
- try {
1605
- if (unreadable) throw new Error('unreadable');
1606
- const changes = [];
1607
- for (const move of moves) changes.push(await movedFileChange(tracker.worktreeRoot, move));
1608
- foldTrackedChanges(tracker, changes, null);
1609
- if (!tracker.unifiedDiff) releaseDiffTrackerContent(tracker);
1610
- } catch {
1611
- tracker.valid = false;
1612
- releaseDiffTrackerContent(tracker);
1613
- refreshUnifiedDiff(tracker);
1803
+ /** Reverse-apply (`reverse`) or forward-apply the record files `targets`
1804
+ * against the current disk, all-or-nothing: `git apply` checks every hunk
1805
+ * (every binary file's exact bytes) before it writes, so an overlapping
1806
+ * foreign edit fails the whole call and nothing is overwritten. Then their
1807
+ * state is recorded. Resolves to the updated record. */
1808
+ async function mutateRecordFiles(ownerSessionId, record, targets, reverse) {
1809
+ const verb = reverse ? 'undone' : 'reapplied';
1810
+ const checkpointId = clean(record.checkpointId);
1811
+ const blocked = targets.find((entry) => entry.revertable !== true);
1812
+ if (blocked) {
1813
+ throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
1614
1814
  }
1615
- }
1616
-
1617
- /** Bring the latest-turn review (composer bar, session Diff) in line with
1618
- * the disk an Undo/Reapply just changed: signal the moved paths, fold the
1619
- * mutation into the exact tracker, re-capture the Git snapshot (sealed ones
1620
- * too) and persist its attribution. Best-effort: the mutation itself
1621
- * already landed. */
1622
- async function refreshLatestTurnReview(ownerSessionId, root, targets, fold) {
1623
- noteTurnWorktreeChange(
1624
- targets
1625
- .flatMap((entry) => [entry.path, entry.oldPath])
1626
- .filter(Boolean)
1627
- .map((path) => resolve(root, path))
1628
- );
1629
- if (fold?.tracker.valid) await foldReviewMutation(fold);
1630
- const tracker = _diffTrackersBySession.get(ownerSessionId);
1631
- if (!tracker?.worktreeSnapshot) return;
1815
+ await applyTurnChangeFiles(record, targets, { reverse });
1816
+ // State lands only after git applied every target; a state that cannot
1817
+ // land takes the worktree change back with it.
1818
+ const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
1819
+ let updated;
1632
1820
  try {
1633
- await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
1634
- } catch {
1635
- // Best-effort refresh: persist the last captured state.
1821
+ updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
1822
+ } catch (error) {
1823
+ try {
1824
+ await applyTurnChangeFiles(record, targets, { reverse: !reverse });
1825
+ } catch (rollbackError) {
1826
+ await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
1827
+ await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
1828
+ throw new Error(
1829
+ `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`,
1830
+ { cause: error }
1831
+ );
1832
+ }
1833
+ throw error;
1636
1834
  }
1637
- if (tracker.sealed) await persistTurnSnapshotRecord(ownerSessionId, tracker);
1835
+ await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
1836
+ return updated;
1638
1837
  }
1639
1838
 
1640
1839
  /** Undo (`reverse`) or Reapply one file or the whole turn, all-or-nothing. */
@@ -1663,32 +1862,7 @@ async function mutateTurnChanges(sessionId, checkpointIdValue, fileValue, revisi
1663
1862
  targets = (record.files || []).filter((entry) => fileState(entry) === from);
1664
1863
  if (targets.length === 0) throw new Error(`this turn has no changes to be ${verb}`);
1665
1864
  }
1666
- const blocked = targets.find((entry) => entry.revertable !== true);
1667
- if (blocked) {
1668
- throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
1669
- }
1670
- const fold = await trackedReviewFold(ownerSessionId, record.root, targets, reverse);
1671
- const root = await applyTurnChangeFiles(record, targets, { reverse });
1672
- // State lands only after git applied every target; a state that cannot
1673
- // land takes the worktree change back with it.
1674
- const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
1675
- let updated;
1676
- try {
1677
- updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
1678
- } catch (error) {
1679
- try {
1680
- await applyTurnChangeFiles(record, targets, { reverse: !reverse });
1681
- } catch (rollbackError) {
1682
- await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
1683
- await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
1684
- throw new Error(
1685
- `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`,
1686
- { cause: error }
1687
- );
1688
- }
1689
- throw error;
1690
- }
1691
- await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
1865
+ const updated = await mutateRecordFiles(ownerSessionId, record, targets, reverse);
1692
1866
  return turnChangesReply({
1693
1867
  status: updated ? 'ready' : 'missing',
1694
1868
  checkpointId,