mixdog 1.2.2 → 1.2.4

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 (692) hide show
  1. package/README.md +18 -0
  2. package/package.json +2 -1
  3. package/scripts/gate-local.mjs +6 -4
  4. package/scripts/lib/gate-scheduler.mjs +29 -11
  5. package/scripts/probe-provider-catalog.mjs +31 -0
  6. package/scripts/provider-pingpong-live.mjs +599 -0
  7. package/scripts/session-sweep.mjs +5 -25
  8. package/src/agents/advisor/AGENT.md +1 -1
  9. package/src/agents/advisor/agent.json +1 -1
  10. package/src/agents/maintainer/AGENT.md +1 -1
  11. package/src/agents/maintainer/agent.json +1 -1
  12. package/src/agents/reviewer/AGENT.md +2 -4
  13. package/src/agents/security/AGENT.md +1 -2
  14. package/src/agents/writer/AGENT.md +2 -3
  15. package/src/defaults/shipped-history.json +11 -1
  16. package/src/defaults/skills/image/SKILL.md +21 -23
  17. package/src/defaults/skills/setup/SKILL.md +6 -1
  18. package/src/defaults/skills/setup/references/actions.md +44 -22
  19. package/src/defaults/skills/setup/references/surfaces.md +10 -8
  20. package/src/headless-exec.mjs +15 -8
  21. package/src/lib/keychain-cjs.cjs +20 -15
  22. package/src/output-styles/common.md +11 -10
  23. package/src/rules/agent/AGENT.md +6 -4
  24. package/src/rules/lead/LEAD.md +7 -9
  25. package/src/rules/shared/45-destructive.md +4 -0
  26. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +28 -7
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -11
  29. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +92 -61
  30. package/src/runtime/agent/orchestrator/cache-mtime.mjs +10 -1
  31. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +7 -0
  32. package/src/runtime/agent/orchestrator/config.mjs +5 -0
  33. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -0
  34. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +26 -7
  35. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +112 -7
  36. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +101 -23
  37. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +2 -0
  38. package/src/runtime/agent/orchestrator/providers/anthropic-api-key-fetch.mjs +36 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +48 -20
  40. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +3 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +24 -17
  42. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +9 -9
  43. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/gzip-post.mjs +26 -44
  45. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +31 -15
  46. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-blocks.mjs +20 -1
  47. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-delta.mjs +15 -2
  48. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-start.mjs +11 -4
  49. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/message-events.mjs +3 -0
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events.mjs +12 -5
  51. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-outcome.mjs +1 -0
  52. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-state.mjs +14 -0
  53. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -0
  54. package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +9 -0
  55. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +10 -0
  56. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +41 -12
  57. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +13 -1
  58. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +131 -9
  59. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +29 -10
  60. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +125 -8
  61. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +12 -62
  62. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +117 -41
  63. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  64. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +117 -72
  65. package/src/runtime/agent/orchestrator/providers/compat-request-policy.mjs +78 -2
  66. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +39 -5
  67. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/providers/cursor-native-context.mjs +48 -0
  69. package/src/runtime/agent/orchestrator/providers/cursor-wire-exec.mjs +144 -34
  70. package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +28 -4
  71. package/src/runtime/agent/orchestrator/providers/cursor-wire-request.mjs +8 -4
  72. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +42 -11
  73. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-messages.mjs +109 -6
  74. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +17 -4
  75. package/src/runtime/agent/orchestrator/providers/cursor.mjs +22 -2
  76. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +73 -7
  77. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +75 -6
  78. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +34 -7
  79. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +32 -6
  80. package/src/runtime/agent/orchestrator/providers/deepseek.mjs +67 -0
  81. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +29 -5
  82. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +11 -4
  83. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +36 -13
  84. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +101 -11
  85. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +41 -5
  86. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +1 -0
  87. package/src/runtime/agent/orchestrator/providers/gemini-rest-stream/watchdogs.mjs +16 -5
  88. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +19 -14
  89. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +371 -41
  90. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -2
  91. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +97 -49
  92. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +81 -10
  93. package/src/runtime/agent/orchestrator/providers/gemini.mjs +35 -8
  94. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -0
  96. package/src/runtime/agent/orchestrator/providers/lib/action-input-contract.mjs +60 -0
  97. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +1 -0
  98. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +253 -42
  99. package/src/runtime/agent/orchestrator/providers/lib/anthropic-server-tool-usage.mjs +18 -0
  100. package/src/runtime/agent/orchestrator/providers/lib/anthropic-system-blocks.mjs +27 -6
  101. package/src/runtime/agent/orchestrator/providers/lib/claude-bridge-schema.mjs +392 -0
  102. package/src/runtime/agent/orchestrator/providers/lib/inclusive-usage.mjs +54 -0
  103. package/src/runtime/agent/orchestrator/providers/lib/origin-bound-fetch.mjs +16 -0
  104. package/src/runtime/agent/orchestrator/providers/lib/request-compression.mjs +86 -0
  105. package/src/runtime/agent/orchestrator/providers/lib/responses-terminal-fields.mjs +11 -0
  106. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +8 -0
  107. package/src/runtime/agent/orchestrator/providers/lib/wire-pairing.mjs +50 -22
  108. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +133 -21
  109. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +13 -15
  110. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +7 -1
  111. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +1 -1
  112. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +1 -0
  113. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +92 -16
  114. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +13 -13
  115. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +133 -62
  117. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +35 -14
  118. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +5 -2
  119. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +39 -7
  120. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/tool-call-acc.mjs +2 -9
  121. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +5 -0
  122. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +1 -1
  123. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +84 -2
  124. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +47 -25
  125. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +5 -1
  126. package/src/runtime/agent/orchestrator/providers/openai-compat-reasoning-field.mjs +155 -0
  127. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +40 -24
  128. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +63 -25
  129. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2901 -0
  130. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-parity.fixtures.mjs +183 -0
  131. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +87 -6
  132. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +51 -3
  133. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -1
  134. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +103 -26
  136. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +23 -4
  137. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +11 -6
  138. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -2
  139. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +15 -23
  140. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +64 -52
  141. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +23 -10
  142. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +28 -5
  143. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +10 -0
  144. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-items.mjs +18 -7
  145. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +4 -0
  146. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +5 -0
  147. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/text-relay.mjs +9 -1
  148. package/src/runtime/agent/orchestrator/providers/openai-model-capabilities.mjs +80 -0
  149. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +3 -1
  150. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -0
  151. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +7 -1
  152. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +72 -24
  153. package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +323 -0
  154. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +50 -16
  155. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +7 -19
  156. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/refusals.mjs +60 -0
  157. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/tool-calls.mjs +51 -36
  158. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state.mjs +30 -0
  159. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +12 -0
  160. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/terminal-frames.mjs +25 -6
  161. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -9
  162. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +181 -0
  163. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +78 -70
  164. package/src/runtime/agent/orchestrator/providers/openrouter.mjs +74 -0
  165. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +150 -10
  166. package/src/runtime/agent/orchestrator/providers/registry.mjs +13 -2
  167. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +7 -1
  168. package/src/runtime/agent/orchestrator/providers/transport-scope.d.mts +6 -0
  169. package/src/runtime/agent/orchestrator/providers/transport-scope.mjs +28 -0
  170. package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +164 -0
  171. package/src/runtime/agent/orchestrator/providers/wire-route.mjs +522 -0
  172. package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +1131 -0
  173. package/src/runtime/agent/orchestrator/providers/xai-tool-search.fixtures.mjs +195 -0
  174. package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +104 -0
  175. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +8 -6
  176. package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +24 -0
  177. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -0
  178. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +30 -2
  179. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +3 -2
  180. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +8 -3
  181. package/src/runtime/agent/orchestrator/runtime-core/provider-request-snapshot.mjs +7 -3
  182. package/src/runtime/agent/orchestrator/runtime-core/session-title.mjs +21 -1
  183. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +16 -3
  184. package/src/runtime/agent/orchestrator/runtime-core/tool-bridge-contract.mjs +6 -0
  185. package/src/runtime/agent/orchestrator/runtime-core/tool-bridge.mjs +526 -0
  186. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  187. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +10 -7
  188. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +178 -71
  189. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +102 -12
  190. package/src/runtime/agent/orchestrator/runtime-core/tool-exposure.mjs +96 -0
  191. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -8
  192. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +1 -2
  193. package/src/runtime/agent/orchestrator/session/agent-pool-progress.mjs +24 -0
  194. package/src/runtime/agent/orchestrator/session/cache/tool-result-reuse.mjs +10 -2
  195. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +2 -3
  196. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +5 -11
  197. package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +6 -22
  198. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -2
  199. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +16 -93
  200. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +16 -2
  201. package/src/runtime/agent/orchestrator/session/compact/summary-anchor.mjs +5 -0
  202. package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +148 -0
  203. package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +1 -1
  204. package/src/runtime/agent/orchestrator/session/context-tool-schema.mjs +17 -2
  205. package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -4
  206. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +0 -6
  207. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +0 -6
  208. package/src/runtime/agent/orchestrator/session/eager-dispatch/admission.mjs +3 -2
  209. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +6 -1
  210. package/src/runtime/agent/orchestrator/session/historical-session-closure.mjs +10 -0
  211. package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +2 -5
  212. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +89 -1
  213. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +1 -1
  214. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +56 -0
  215. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +18 -8
  216. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -27
  217. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/output-limit.mjs +1 -1
  218. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -10
  219. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -9
  220. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +24 -10
  221. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +135 -0
  222. package/src/runtime/agent/orchestrator/session/loop/request-boundary.mjs +31 -4
  223. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +76 -0
  224. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +67 -20
  225. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +10 -10
  226. package/src/runtime/agent/orchestrator/session/loop/tool-call-resolution.mjs +95 -0
  227. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +6 -0
  228. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +30 -3
  229. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +29 -5
  230. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
  231. package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +147 -0
  232. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +7 -5
  233. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +33 -3
  234. package/src/runtime/agent/orchestrator/session/manager/ask-support.mjs +0 -4
  235. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +23 -2
  236. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +34 -12
  237. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +54 -13
  238. package/src/runtime/agent/orchestrator/session/manager/ask-turn-failure.mjs +33 -0
  239. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +57 -25
  240. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +11 -0
  241. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +22 -0
  242. package/src/runtime/agent/orchestrator/session/manager/ask-turn-state.mjs +57 -13
  243. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  244. package/src/runtime/agent/orchestrator/session/manager/failed-turn-rewind.mjs +16 -0
  245. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +6 -120
  246. package/src/runtime/agent/orchestrator/session/manager/pending-persist-queue.mjs +1 -1
  247. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +231 -16
  248. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +42 -49
  249. package/src/runtime/agent/orchestrator/session/manager/runtime-state.mjs +50 -0
  250. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +47 -17
  251. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +26 -4
  252. package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +82 -0
  253. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +36 -35
  254. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +29 -13
  255. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +6 -3
  256. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  257. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-recovery.mjs +45 -0
  258. package/src/runtime/agent/orchestrator/session/manager.mjs +4 -10
  259. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +6 -0
  260. package/src/runtime/agent/orchestrator/session/send-instrumentation.mjs +2 -1
  261. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +17 -1
  262. package/src/runtime/agent/orchestrator/session/side-question-store.mjs +96 -0
  263. package/src/runtime/agent/orchestrator/session/side-question.mjs +197 -0
  264. package/src/runtime/agent/orchestrator/session/store/lifecycle-barriers.mjs +22 -15
  265. package/src/runtime/agent/orchestrator/session/store/lifecycle-read.mjs +1 -1
  266. package/src/runtime/agent/orchestrator/session/store/listing.mjs +2 -6
  267. package/src/runtime/agent/orchestrator/session/store/live-cache-eviction.mjs +1 -1
  268. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +23 -0
  269. package/src/runtime/agent/orchestrator/session/store/liveness-veto.mjs +46 -37
  270. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +1 -1
  271. package/src/runtime/agent/orchestrator/session/store/load-session.mjs +1 -1
  272. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +44 -37
  273. package/src/runtime/agent/orchestrator/session/store/session-observers.mjs +4 -2
  274. package/src/runtime/agent/orchestrator/session/store/session-paths.mjs +59 -0
  275. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +1 -1
  276. package/src/runtime/agent/orchestrator/session/store/sweep/stale-sweep.mjs +1 -1
  277. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +27 -21
  278. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-plan.mjs +0 -8
  279. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-record.mjs +6 -18
  280. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-row.mjs +33 -106
  281. package/src/runtime/agent/orchestrator/session/store/write-admission.mjs +4 -2
  282. package/src/runtime/agent/orchestrator/session/store-agent-worker-pool.mjs +5 -8
  283. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +136 -196
  284. package/src/runtime/agent/orchestrator/session/store-summary-archived-agent.mjs +34 -15
  285. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +53 -115
  286. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +8 -0
  287. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +10 -1
  288. package/src/runtime/agent/orchestrator/session/store-summary-locations.mjs +3 -29
  289. package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +1 -1
  290. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +17 -10
  291. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +9 -20
  292. package/src/runtime/agent/orchestrator/session/store.mjs +22 -19
  293. package/src/runtime/agent/orchestrator/session/tool-batch/execute-call.mjs +12 -12
  294. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +38 -14
  295. package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +15 -4
  296. package/src/runtime/agent/orchestrator/session/tool-batch/record-outcome.mjs +1 -1
  297. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +7 -1
  298. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -0
  299. package/src/runtime/agent/orchestrator/session/tool-schema-rejection.mjs +79 -0
  300. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +72 -15
  301. package/src/runtime/agent/orchestrator/session/turn-failure.mjs +45 -0
  302. package/src/runtime/agent/orchestrator/stall-policy.mjs +50 -0
  303. package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +8 -2
  304. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +3 -3
  305. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +5 -1
  306. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/inline-hoist.mjs +6 -2
  307. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  308. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +218 -20
  309. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +160 -39
  310. package/src/runtime/agent/orchestrator/tools/builtin/git-partial-stage.mjs +7 -5
  311. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +14 -5
  312. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +4 -3
  313. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +2 -1
  314. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-path-fanout.mjs +48 -44
  315. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +10 -3
  316. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +247 -49
  317. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-task-output.mjs +5 -4
  318. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +6 -1
  319. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +23 -1
  320. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +20 -0
  321. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +101 -2
  322. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +1 -1
  323. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1 -1
  324. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +1 -1
  325. package/src/runtime/agent/orchestrator/tools/lib/owned-shell-trees.mjs +67 -0
  326. package/src/runtime/agent/orchestrator/tools/lib/process-table-fixture.mjs +50 -0
  327. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +303 -63
  328. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +2 -0
  329. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +108 -18
  330. package/src/runtime/agent/orchestrator/tools/lib/shell-run-spawn.mjs +77 -11
  331. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +81 -5
  332. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +73 -39
  333. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +18 -1
  334. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -4
  335. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +88 -12
  336. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +2 -0
  337. package/src/runtime/attachments/store.d.mts +9 -0
  338. package/src/runtime/attachments/store.mjs +80 -9
  339. package/src/runtime/browser-bridge/browser-action-contract.d.mts +0 -1
  340. package/src/runtime/browser-bridge/browser-action-contract.mjs +0 -2
  341. package/src/runtime/computer-bridge/action-schema.mjs +219 -196
  342. package/src/runtime/computer-bridge/linux-readiness.d.mts +0 -2
  343. package/src/runtime/computer-bridge/linux-readiness.mjs +0 -16
  344. package/src/runtime/effort-judge/judge-client.mjs +21 -4
  345. package/src/runtime/effort-judge/judge-worker.mjs +4 -1
  346. package/src/runtime/github/tool.mjs +208 -17
  347. package/src/runtime/media/adapters/codex-image.mjs +140 -21
  348. package/src/runtime/media/catalog.mjs +4 -4
  349. package/src/runtime/media/chatgpt-usage.mjs +55 -0
  350. package/src/runtime/media/defaults.mjs +37 -0
  351. package/src/runtime/media/jobs.mjs +12 -2
  352. package/src/runtime/media/lanes.mjs +12 -0
  353. package/src/runtime/media/tool-defs.mjs +15 -4
  354. package/src/runtime/media/tool.mjs +41 -12
  355. package/src/runtime/media/upstream-error.mjs +72 -6
  356. package/src/runtime/memory/index.mjs +1 -0
  357. package/src/runtime/memory/lib/http-router/ingest-routes.mjs +22 -1
  358. package/src/runtime/memory/lib/ingest/transcript-offsets.mjs +2 -0
  359. package/src/runtime/memory/lib/ingest/transcript-rows.mjs +1 -0
  360. package/src/runtime/memory/lib/pg/adapter.mjs +98 -1
  361. package/src/runtime/memory/lib/pg/process.mjs +36 -33
  362. package/src/runtime/memory/lib/pg/run-child.mjs +49 -0
  363. package/src/runtime/memory/lib/pg/supervisor.mjs +112 -4
  364. package/src/runtime/memory/lib/runtime-fetcher.mjs +12 -13
  365. package/src/runtime/memory/lib/transcript-ingest.mjs +14 -0
  366. package/src/runtime/office/README.md +1 -1
  367. package/src/runtime/office/authoring/docx-html-runner.mjs +1 -1
  368. package/src/runtime/office/authoring/html-browser.mjs +18 -8
  369. package/src/runtime/office/authoring/html-document-author.mjs +2 -0
  370. package/src/runtime/office/authoring/pdf-html-runner.mjs +1 -1
  371. package/src/runtime/office/authoring/pptx-contact-sheet.mjs +2 -0
  372. package/src/runtime/office/authoring/pptx-html-measure.mjs +3 -1
  373. package/src/runtime/office/authoring/pptx-html-runner.mjs +2 -0
  374. package/src/runtime/office/authoring/xlsx-html-runner.mjs +3 -1
  375. package/src/runtime/office/core/office-actions-read.mjs +3 -0
  376. package/src/runtime/office/core/office-render-preview.mjs +2 -0
  377. package/src/runtime/office/core/office-session-dispatch.mjs +7 -0
  378. package/src/runtime/office/core/office-sessionless-actions.mjs +8 -0
  379. package/src/runtime/office/core/office-sessions.mjs +5 -0
  380. package/src/runtime/office/core/office-transactions.mjs +3 -0
  381. package/src/runtime/office/core/office-write-ledger.mjs +94 -0
  382. package/src/runtime/office/design/library/templates/mixdog-executive.mixdog-edit.pptx +0 -0
  383. package/src/runtime/office/index.mjs +29 -0
  384. package/src/runtime/office/pdf/pdf-analysis.mjs +0 -68
  385. package/src/runtime/office/pdf/pdf-edit-document.mjs +2 -0
  386. package/src/runtime/office/pdf/pdf-render.mjs +23 -7
  387. package/src/runtime/office/pdf/pdf-security.mjs +2 -0
  388. package/src/runtime/office/portable/portable-opc.mjs +4 -1
  389. package/src/runtime/office/quality/visual-diff.mjs +2 -0
  390. package/src/runtime/session-search/session-search-ingest.mjs +1 -1
  391. package/src/runtime/shared/agent-reap-state.mjs +13 -0
  392. package/src/runtime/shared/atomic-file.d.mts +1 -1
  393. package/src/runtime/shared/background-tasks.mjs +6 -1
  394. package/src/runtime/shared/bounded-file-read.mjs +13 -0
  395. package/src/runtime/shared/bounded-line-diff.mjs +191 -0
  396. package/src/runtime/shared/browser-launch.mjs +15 -0
  397. package/src/runtime/shared/buffered-appender.mjs +18 -0
  398. package/src/runtime/shared/config.mjs +78 -18
  399. package/src/runtime/shared/err-text.mjs +48 -4
  400. package/src/runtime/shared/file-lock.mjs +6 -5
  401. package/src/runtime/shared/file-permissions.d.mts +6 -1
  402. package/src/runtime/shared/file-permissions.mjs +54 -6
  403. package/src/runtime/shared/llm/anthropic-betas.mjs +1 -2
  404. package/src/runtime/shared/llm/anthropic-effort.mjs +6 -0
  405. package/src/runtime/shared/llm/provider-model-identities.mjs +1 -0
  406. package/src/runtime/shared/llm/usage-accounting.mjs +5 -2
  407. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +3 -0
  408. package/src/runtime/shared/loopback-listener.mjs +9 -0
  409. package/src/runtime/shared/model-route-chain.d.mts +8 -0
  410. package/src/runtime/shared/model-route-chain.mjs +48 -0
  411. package/src/runtime/shared/model-route-fallback.mjs +22 -0
  412. package/src/runtime/shared/pid-liveness.mjs +12 -0
  413. package/src/runtime/shared/provider-api-key.mjs +8 -0
  414. package/src/runtime/shared/provider-timeout-phase.mjs +17 -0
  415. package/src/runtime/shared/quiet-progress.d.mts +35 -0
  416. package/src/runtime/shared/quiet-progress.mjs +101 -0
  417. package/src/runtime/shared/quota-resume-store.mjs +32 -1
  418. package/src/runtime/shared/read-row-numbers.mjs +44 -0
  419. package/src/runtime/shared/remote-intent.mjs +2 -42
  420. package/src/runtime/shared/review-diff.mjs +52 -0
  421. package/src/runtime/shared/session-sidebar-host.d.mts +4 -0
  422. package/src/runtime/shared/session-sidebar-host.mjs +38 -0
  423. package/src/runtime/shared/slow-span.mjs +121 -0
  424. package/src/runtime/shared/statusline/statusline-segments.mjs +52 -6
  425. package/src/runtime/shared/tool-card-model/card-header.mjs +7 -1
  426. package/src/runtime/shared/tool-card-model/generic-detail.mjs +39 -5
  427. package/src/runtime/shared/tool-card-model.mjs +5 -2
  428. package/src/runtime/shared/tool-output-budget.mjs +2 -0
  429. package/src/runtime/shared/tool-output-limit.mjs +4 -0
  430. package/src/runtime/shared/tool-primitives.mjs +13 -0
  431. package/src/runtime/shared/tool-result-summary.mjs +22 -1
  432. package/src/runtime/shared/tool-status.mjs +3 -2
  433. package/src/runtime/shared/tool-surface.mjs +20 -6
  434. package/src/runtime/shared/tool-work-units.mjs +54 -11
  435. package/src/runtime/shared/transcript-metadata.mjs +49 -1
  436. package/src/runtime/shared/transcript-turn-keys.mjs +33 -0
  437. package/src/runtime/shared/transcript-writer/purge.mjs +74 -0
  438. package/src/runtime/shared/transcript-writer/transcript-file.mjs +10 -1
  439. package/src/runtime/shared/transcript-writer.mjs +24 -6
  440. package/src/runtime/shared/turn-changes.mjs +269 -100
  441. package/src/runtime/shared/turn-review-reply.mjs +8 -39
  442. package/src/runtime/shared/turn-snapshot-store.mjs +19 -108
  443. package/src/runtime/shared/turn-snapshot.mjs +865 -683
  444. package/src/runtime/shared/turn-worktree-snapshot.mjs +183 -255
  445. package/src/runtime/shared/user-data-guard.mjs +262 -49
  446. package/src/runtime/shared/worktrees/activity-gate.mjs +175 -0
  447. package/src/runtime/shared/worktrees/branch-management.mjs +179 -0
  448. package/src/runtime/shared/worktrees/carry.mjs +533 -0
  449. package/src/runtime/shared/worktrees/contract.mjs +60 -0
  450. package/src/runtime/shared/worktrees/dependencies.mjs +456 -0
  451. package/src/runtime/shared/worktrees/file-recovery.mjs +84 -0
  452. package/src/runtime/shared/worktrees/git-head.mjs +214 -0
  453. package/src/runtime/shared/worktrees/git.mjs +8 -3
  454. package/src/runtime/shared/worktrees/include.mjs +125 -7
  455. package/src/runtime/shared/worktrees/lock.mjs +1 -1
  456. package/src/runtime/shared/worktrees/maintenance.mjs +20 -2
  457. package/src/runtime/shared/worktrees/manager.mjs +525 -31
  458. package/src/runtime/shared/worktrees/merge.mjs +31 -21
  459. package/src/runtime/shared/worktrees/owner.mjs +2 -1
  460. package/src/runtime/shared/worktrees/paths.d.mts +6 -0
  461. package/src/runtime/shared/worktrees/preparation-report.mjs +79 -0
  462. package/src/runtime/shared/worktrees/setup-commands.mjs +232 -0
  463. package/src/runtime/shared/worktrees/workspace-visibility.mjs +17 -0
  464. package/src/runtime/terminal-bridge/client.mjs +57 -20
  465. package/src/runtime/terminal-bridge/tool-defs.mjs +13 -6
  466. package/src/runtime/tidy/apply.mjs +47 -2
  467. package/src/runtime/tidy/run-engines.mjs +11 -5
  468. package/src/session-runtime/agent-tool-routing.mjs +19 -13
  469. package/src/session-runtime/boot/apis.mjs +20 -6
  470. package/src/session-runtime/boot/config.mjs +2 -0
  471. package/src/session-runtime/boot/facade.mjs +4 -0
  472. package/src/session-runtime/boot/infrastructure.mjs +6 -1
  473. package/src/session-runtime/boot/session.mjs +3 -3
  474. package/src/session-runtime/boot/tools.mjs +7 -1
  475. package/src/session-runtime/conversation-import/service.mjs +1 -1
  476. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +12 -5
  477. package/src/session-runtime/goal-facade-api.mjs +2 -2
  478. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +91 -0
  479. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -1
  480. package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +15 -5
  481. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +137 -26
  482. package/src/session-runtime/internal-tool-executor.mjs +8 -2
  483. package/src/session-runtime/lifecycle/cold-heir.mjs +187 -0
  484. package/src/session-runtime/lifecycle/inheritance.mjs +71 -27
  485. package/src/session-runtime/lifecycle/session-catalog.mjs +25 -18
  486. package/src/session-runtime/lifecycle/session-switch/resume.mjs +6 -0
  487. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +36 -48
  488. package/src/session-runtime/lifecycle/session-switch.mjs +1 -1
  489. package/src/session-runtime/lifecycle/shared.mjs +5 -6
  490. package/src/session-runtime/lifecycle/teardown/session-close.mjs +6 -5
  491. package/src/session-runtime/lifecycle/teardown.mjs +70 -14
  492. package/src/session-runtime/lifecycle-api.mjs +1 -0
  493. package/src/session-runtime/mcp-glue/connect.mjs +17 -5
  494. package/src/session-runtime/media-api.mjs +8 -0
  495. package/src/session-runtime/model-route/route-ownership.mjs +60 -0
  496. package/src/session-runtime/model-route/route-persist.mjs +3 -2
  497. package/src/session-runtime/model-route/set-route.mjs +116 -36
  498. package/src/session-runtime/model-route/web-search-route.mjs +7 -2
  499. package/src/session-runtime/model-route-api.mjs +4 -2
  500. package/src/session-runtime/native-web-search/routes.mjs +37 -24
  501. package/src/session-runtime/native-web-search-usage.mjs +51 -0
  502. package/src/session-runtime/native-web-search.mjs +19 -9
  503. package/src/session-runtime/new-session-config.mjs +4 -2
  504. package/src/session-runtime/provider-auth/login.mjs +4 -0
  505. package/src/session-runtime/provider-auth/setup.mjs +4 -0
  506. package/src/session-runtime/provider-models/catalog-load.mjs +9 -0
  507. package/src/session-runtime/provider-models/web-search-catalog.mjs +23 -3
  508. package/src/session-runtime/provider-models.mjs +1 -0
  509. package/src/session-runtime/provider-usage.mjs +4 -0
  510. package/src/session-runtime/resource-scope-decorate.mjs +10 -3
  511. package/src/session-runtime/route-resolution.mjs +3 -1
  512. package/src/session-runtime/runtime-core.mjs +4 -0
  513. package/src/session-runtime/runtime-facade.mjs +6 -0
  514. package/src/session-runtime/runtime-review-api.mjs +8 -2
  515. package/src/session-runtime/services/agent-task-status.mjs +2 -0
  516. package/src/session-runtime/services/agent-tool/close-flow/close-all.mjs +0 -3
  517. package/src/session-runtime/services/agent-tool/close-flow.mjs +6 -7
  518. package/src/session-runtime/services/agent-tool/execute-send.mjs +2 -2
  519. package/src/session-runtime/services/agent-tool/execute-spawn.mjs +36 -3
  520. package/src/session-runtime/services/agent-tool/execute.mjs +9 -1
  521. package/src/session-runtime/services/agent-tool/helpers.mjs +30 -7
  522. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +81 -20
  523. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +7 -2
  524. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +6 -3
  525. package/src/session-runtime/services/agent-tool/lead-worker-index/forgotten-leads.mjs +29 -0
  526. package/src/session-runtime/services/agent-tool/lead-worker-index/index-file.mjs +1 -1
  527. package/src/session-runtime/services/agent-tool/lead-worker-index/lead-rows.mjs +3 -4
  528. package/src/session-runtime/services/agent-tool/lead-worker-index/reap-timers.mjs +9 -3
  529. package/src/session-runtime/services/agent-tool/lead-worker-index/row-settlement.mjs +7 -18
  530. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +82 -13
  531. package/src/session-runtime/services/agent-tool/model-select.mjs +82 -0
  532. package/src/session-runtime/services/agent-tool/send-flow.mjs +24 -6
  533. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +3 -1
  534. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -5
  535. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +5 -4
  536. package/src/session-runtime/services/agent-tool/spawn-flow.mjs +2 -1
  537. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
  538. package/src/session-runtime/services/agent-tool/tag-registry/tag-lookup.mjs +1 -3
  539. package/src/session-runtime/services/agent-tool/tag-registry/tag-refresh.mjs +1 -3
  540. package/src/session-runtime/services/agent-tool/tag-registry.mjs +18 -10
  541. package/src/session-runtime/services/agent-tool/terminal-reaper/scan-admission.mjs +8 -41
  542. package/src/session-runtime/services/agent-tool/terminal-reaper/stale-rows.mjs +3 -3
  543. package/src/session-runtime/services/agent-tool/terminal-reaper.mjs +6 -23
  544. package/src/session-runtime/services/agent-tool/terminal-trace.mjs +0 -1
  545. package/src/session-runtime/services/agent-tool/tool-def.mjs +7 -0
  546. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +32 -20
  547. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -2
  548. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +10 -1
  549. package/src/session-runtime/services/agent-tool/worker-index.mjs +74 -15
  550. package/src/session-runtime/services/agent-tool.mjs +7 -0
  551. package/src/session-runtime/services/agent-watchdog-registry.mjs +2 -0
  552. package/src/session-runtime/services/channel-admin.mjs +19 -3
  553. package/src/session-runtime/services/memory-runtime-proxy/calls.mjs +4 -0
  554. package/src/session-runtime/services/memory-runtime-proxy.mjs +1 -0
  555. package/src/session-runtime/services/provider-admin.mjs +158 -15
  556. package/src/session-runtime/session-create/session-options.mjs +7 -5
  557. package/src/session-runtime/session-create/session-start-hook.mjs +2 -1
  558. package/src/session-runtime/session-transcript-purge.mjs +32 -0
  559. package/src/session-runtime/session-transcript.mjs +4 -5
  560. package/src/session-runtime/session-turn-api.mjs +17 -14
  561. package/src/session-runtime/settings-builtin-tools-api.mjs +18 -6
  562. package/src/session-runtime/setup-tool/executor.mjs +57 -9
  563. package/src/session-runtime/setup-tool/extended-actions.mjs +86 -6
  564. package/src/session-runtime/setup-tool/settings-contract.mjs +18 -3
  565. package/src/session-runtime/setup-tool/tool-defs.mjs +25 -4
  566. package/src/session-runtime/skills-api.mjs +3 -5
  567. package/src/session-runtime/tool-policy-refresh.mjs +83 -32
  568. package/src/session-runtime/tool-surface.mjs +5 -1
  569. package/src/session-runtime/turn/agent-ops.mjs +49 -2
  570. package/src/session-runtime/turn/compaction-gate.mjs +25 -0
  571. package/src/session-runtime/turn/session-ops.mjs +7 -5
  572. package/src/session-runtime/turn/side-question-ops.mjs +19 -0
  573. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +7 -1
  574. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +12 -3
  575. package/src/session-runtime/turn/turn-run/turn-settle.mjs +18 -2
  576. package/src/session-runtime/turn/turn-run/turn-timing.mjs +4 -3
  577. package/src/session-runtime/turn/turn-run.mjs +26 -45
  578. package/src/session-runtime/usage-stats-api.mjs +46 -11
  579. package/src/session-runtime/workflow-agents-api/agent-route.mjs +16 -1
  580. package/src/session-runtime/workspace/admission-gate.mjs +44 -15
  581. package/src/session-runtime/workspace/project-branches.mjs +35 -41
  582. package/src/session-runtime/workspace/transition-steps.mjs +215 -116
  583. package/src/session-runtime/workspace/transitions.mjs +428 -73
  584. package/src/standalone/channel-transport.mjs +3 -4
  585. package/src/standalone/daemon-agent-control.mjs +9 -1
  586. package/src/standalone/daemon-desktop-runtime.mjs +32 -1
  587. package/src/standalone/daemon-local-session-bridge.mjs +14 -1
  588. package/src/standalone/daemon-owner.mjs +17 -4
  589. package/src/standalone/daemon-pg-stop.mjs +49 -0
  590. package/src/standalone/daemon-session-control.mjs +88 -0
  591. package/src/standalone/daemon-stop-budget.d.mts +2 -0
  592. package/src/standalone/daemon-stop-budget.mjs +18 -0
  593. package/src/standalone/daemon-stored-session-views.mjs +25 -3
  594. package/src/standalone/daemon-telemetry.mjs +19 -0
  595. package/src/standalone/daemon.mjs +63 -1
  596. package/src/standalone/desktop-service-registry.mjs +5 -0
  597. package/src/standalone/local-session-runtime.mjs +80 -30
  598. package/src/standalone/session-client.mjs +42 -0
  599. package/src/standalone/session-protocol.mjs +14 -0
  600. package/src/standalone/session-proxy/projection.mjs +41 -4
  601. package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +15 -1
  602. package/src/standalone/session-runtime-inline-host.mjs +3 -0
  603. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +91 -49
  604. package/src/standalone/session-service/agent-tree/agent-child.mjs +19 -1
  605. package/src/standalone/session-service/agent-tree/agent-registry.mjs +25 -0
  606. package/src/standalone/session-service/agent-tree/agent-turns.mjs +9 -1
  607. package/src/standalone/session-service/agent-tree.mjs +28 -2
  608. package/src/standalone/session-service/entries/session-loads.mjs +16 -5
  609. package/src/standalone/session-service/entries.mjs +1 -0
  610. package/src/standalone/session-service/projection/transcript-window.mjs +39 -0
  611. package/src/standalone/session-service/quota-resume.mjs +28 -13
  612. package/src/standalone/session-service/session-calls/session-action.mjs +18 -3
  613. package/src/standalone/session-service/session-calls/session-turns.mjs +17 -6
  614. package/src/standalone/session-service/session-calls/session-views.mjs +6 -0
  615. package/src/standalone/session-service-api.mjs +6 -1
  616. package/src/standalone/session-state-patch.d.mts +2 -1
  617. package/src/standalone/session-state-patch.mjs +41 -1
  618. package/src/standalone/session-tool-output.mjs +82 -0
  619. package/src/tui/App.jsx +25 -0
  620. package/src/tui/app/app-view/floating-panel.jsx +26 -1
  621. package/src/tui/app/app-view.jsx +2 -1
  622. package/src/tui/app/btw-controller.mjs +118 -0
  623. package/src/tui/app/create-app-pickers.mjs +2 -0
  624. package/src/tui/app/provider-setup/provider-items.mjs +8 -0
  625. package/src/tui/app/provider-setup-picker.mjs +15 -3
  626. package/src/tui/app/shell-layout/row-budgets.mjs +11 -1
  627. package/src/tui/app/shell-layout/surfaces.mjs +3 -1
  628. package/src/tui/app/slash-commands.mjs +19 -3
  629. package/src/tui/app/slash-dispatch/session-commands.mjs +68 -6
  630. package/src/tui/app/use-global-key-input/approval-shortcut.mjs +12 -0
  631. package/src/tui/app/use-global-key-input.mjs +5 -12
  632. package/src/tui/app/use-prompt-handlers.mjs +11 -0
  633. package/src/tui/components/BtwPanel.jsx +53 -0
  634. package/src/tui/components/QuestionPanel.jsx +275 -0
  635. package/src/tui/components/Spinner.jsx +4 -0
  636. package/src/tui/components/SpinnerByline.jsx +21 -2
  637. package/src/tui/components/question-panel-model.mjs +156 -0
  638. package/src/tui/components/status-line/local-line.mjs +10 -2
  639. package/src/tui/dist/index.mjs +2897 -1722
  640. package/src/tui/session/active-tool-summary.mjs +1 -1
  641. package/src/tui/session/context-state.mjs +4 -5
  642. package/src/tui/session/goal-continuation/continuation-queue.mjs +2 -3
  643. package/src/tui/session/goal-continuation/deadline-delivery.mjs +4 -2
  644. package/src/tui/session/goal-continuation/session-boundary.mjs +21 -0
  645. package/src/tui/session/quota-resume.mjs +140 -61
  646. package/src/tui/session/session-api/commands.mjs +14 -10
  647. package/src/tui/session/session-api/controls.mjs +14 -2
  648. package/src/tui/session/session-api/integrations.mjs +9 -2
  649. package/src/tui/session/session-api/lifecycle/dispose.mjs +3 -9
  650. package/src/tui/session/session-api/lifecycle/resume.mjs +2 -7
  651. package/src/tui/session/session-api/lifecycle.mjs +14 -11
  652. package/src/tui/session/session-api/media.mjs +2 -0
  653. package/src/tui/session/session-api/routes.mjs +81 -57
  654. package/src/tui/session/session-api/settings.mjs +9 -3
  655. package/src/tui/session/session-api/worktree.mjs +3 -2
  656. package/src/tui/session/session-api.mjs +1 -0
  657. package/src/tui/session/session-flow/auto-clear.mjs +1 -3
  658. package/src/tui/session/session-flow/drain.mjs +6 -4
  659. package/src/tui/session/session-flow/queue/entry-shape.mjs +2 -0
  660. package/src/tui/session/session-flow/queue/take-entries.mjs +2 -1
  661. package/src/tui/session/session-flow/queue.mjs +1 -1
  662. package/src/tui/session/tool-approval.mjs +20 -8
  663. package/src/tui/session/transcript-spill.mjs +14 -7
  664. package/src/tui/session/transcript-store/streaming-tail.mjs +1 -1
  665. package/src/tui/session/tui-steering-persist.mjs +3 -5
  666. package/src/tui/session/turn-quiet.mjs +53 -0
  667. package/src/tui/session/turn-stream.mjs +179 -32
  668. package/src/tui/session/turn.mjs +50 -36
  669. package/src/tui/session/worktree-setup-notice.mjs +38 -0
  670. package/src/tui/spinner-meta.mjs +10 -0
  671. package/src/tui/spinner-verbs.mjs +3 -0
  672. package/src/ui/statusline.mjs +13 -3
  673. package/src/workflows/default/WORKFLOW.md +3 -1
  674. package/src/runtime/agent/orchestrator/providers/model-pricing-rates.mjs +0 -1
  675. package/src/runtime/agent/orchestrator/session/manager/foreign-pending-messages.mjs +0 -346
  676. package/src/runtime/agent/orchestrator/session/manager/session-owner-liveness.mjs +0 -117
  677. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +0 -246
  678. package/src/session-runtime/services/agent-tool/terminal-reaper/reap-timers.mjs +0 -142
  679. package/src/session-runtime/turn/share-ops.mjs +0 -77
  680. package/src/tui/session/cross-surface-share.mjs +0 -406
  681. package/src/tui/session/live-share/delta.mjs +0 -153
  682. package/src/tui/session/live-share/mirror.mjs +0 -106
  683. package/src/tui/session/live-share/owner.mjs +0 -254
  684. package/src/tui/session/live-share/retry.mjs +0 -54
  685. package/src/tui/session/live-share/viewer/frame-router.mjs +0 -32
  686. package/src/tui/session/live-share/viewer/pipe-client.mjs +0 -108
  687. package/src/tui/session/live-share/viewer/submit-acks.mjs +0 -65
  688. package/src/tui/session/live-share/viewer/sync-waiters.mjs +0 -53
  689. package/src/tui/session/live-share/viewer.mjs +0 -99
  690. package/src/tui/session/live-share/wire.mjs +0 -44
  691. package/src/tui/session/live-share.mjs +0 -154
  692. package/src/tui/session/shared-dir-watch.mjs +0 -69
@@ -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,68 @@ 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
+ _writeSeq += 1;
653
+ const seq = _writeSeq;
654
+ appendSegment(tracker, {
655
+ before: { path: sourcePath, displayPath: sourceDisplay, content: before },
656
+ after: destination
657
+ ? { path: destinationPath, displayPath: destination.display, content: after }
658
+ : { path: sourcePath, displayPath: sourceDisplay, content: after },
659
+ firstSeq: seq,
660
+ lastSeq: seq,
609
661
  });
610
- tracker.originByCurrentPath.delete(destinationKey);
611
- if (destinationKey !== originKey) tracker.originByCurrentPath.set(destinationKey, originKey);
612
662
  }
613
663
  tracker.revision += 1;
614
664
  if (trackedContentBytes(tracker) > MAX_TRACKED_CONTENT_BYTES) {
615
665
  tracker.valid = false;
616
666
  releaseDiffTrackerContent(tracker);
617
667
  }
618
- refreshUnifiedDiff(tracker);
668
+ }
669
+
670
+ /** Join a completed worker's own segments to its Lead's tracker: both sets
671
+ * replay in write order, so a worker write between two Lead writes of one
672
+ * file continues their segment instead of reading as a foreign gap. A
673
+ * worker whose content was dropped drops the Lead's exact content too. */
674
+ function mergeTrackedContent(target, source) {
675
+ // The worker's owned paths and counts joined as well: any view is stale.
676
+ target.revision += 1;
677
+ if (!target.valid) return;
678
+ if (!source.valid) {
679
+ target.valid = false;
680
+ releaseDiffTrackerContent(target);
681
+ return;
682
+ }
683
+ const segmentsOf = (tracker) => [...tracker.chains.values()].flatMap((chain) => chain.segments);
684
+ const written = new Set(
685
+ segmentsOf(source).flatMap((segment) => [pathKey(segment.before.path), pathKey(segment.after.path)])
686
+ );
687
+ const segments = [...segmentsOf(target), ...segmentsOf(source)].sort((left, right) => left.firstSeq - right.firstSeq);
688
+ target.chains = new Map();
689
+ for (const segment of segments) appendSegment(target, segment);
690
+ dropUndoneChains(target, written);
691
+ if (trackedContentBytes(target) > MAX_TRACKED_CONTENT_BYTES) {
692
+ target.valid = false;
693
+ releaseDiffTrackerContent(target);
694
+ }
619
695
  }
620
696
 
621
697
  function trimAgentReviews(turn) {
@@ -680,13 +756,14 @@ export async function beginTurnSnapshot(worktree, sessionId, options = {}) {
680
756
  // Turn start and Undo/Reapply share the per-session lock: a start waits for
681
757
  // an in-flight mutation, and a mutation queued after this start re-checks
682
758
  // 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.)
759
+ // Late-worker re-seals still queued for the previous turn run first, in
760
+ // this same step — including one a worker queued while an earlier one ran —
761
+ // so the workers' edits stay in that turn's record and nothing the new turn
762
+ // writes can reach it. (A queued re-seal implies a chain.)
686
763
  const { token, tracker } = _changeMutations.has(ownerSessionId)
687
764
  ? await serializeChangeMutation(ownerSessionId, async () => {
688
765
  const previous = _diffTrackersBySession.get(ownerSessionId);
689
- if (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
766
+ while (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
690
767
  return openTurnSnapshot(worktree, ownerSessionId, options);
691
768
  })
692
769
  : openTurnSnapshot(worktree, ownerSessionId, options);
@@ -780,24 +857,29 @@ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {})
780
857
  };
781
858
  }
782
859
 
783
- /** Freeze successful worker apply_patch diffs into their owning Lead turn. */
860
+ /** Freeze successful worker apply_patch diffs into their owning Lead turn.
861
+ * Everything a seal reads — the agent entry, the worker's owned paths and a
862
+ * late re-seal — is published before this returns, so a seal started right
863
+ * after it already includes the worker. */
784
864
  export function completeAgentTurnReview(handle, patches = []) {
785
865
  if (!handle || handle.completed === true) return false;
786
866
  handle.completed = true;
787
- const turn = _turnsBySession.get(clean(handle.ownerSessionId));
867
+ const ownerSessionId = clean(handle.ownerSessionId);
788
868
  const key = clean(handle.sessionId) || handle.id;
869
+ const turn = _turnsBySession.get(ownerSessionId);
789
870
  const tracker = _diffTrackersBySession.get(key);
790
871
  const matchingTracker =
791
- tracker && tracker.ownerSessionId === clean(handle.ownerSessionId) && tracker.ownerGeneration === handle.generation;
872
+ tracker && tracker.ownerSessionId === ownerSessionId && tracker.ownerGeneration === handle.generation;
792
873
  // A worker that runs its own session turn replaces the bound tracker with
793
874
  // its turn's own one (no owner). beginAgentTurnReview reset any older
794
875
  // tracker, so an unowned one here holds this execution's edits.
795
876
  const source = matchingTracker || (tracker && !tracker.ownerSessionId) ? tracker : null;
877
+ const ownerTracker = _diffTrackersBySession.get(ownerSessionId);
796
878
  try {
797
879
  if (!turn || turn.generation !== handle.generation) return false;
798
880
  const prior = turn.agents.get(key);
799
881
  const tracked = Boolean(source) && source.revision > 0;
800
- const patch = tracked ? source.unifiedDiff : mergePatches(patches);
882
+ const patch = tracked ? trackerView(source).patch : mergePatches(patches);
801
883
  if (!patch) {
802
884
  if (tracked && prior) {
803
885
  turn.agents.delete(key);
@@ -812,15 +894,17 @@ export function completeAgentTurnReview(handle, patches = []) {
812
894
  tag: handle.tag || prior?.tag || null,
813
895
  patch: tracked ? patch : mergePatches([prior?.patch, patch]),
814
896
  });
815
- const ownerTracker = _diffTrackersBySession.get(clean(handle.ownerSessionId));
816
897
  if (source && ownerTracker && ownerTracker !== source) {
817
898
  for (const path of source.ownedPaths) ownerTracker.ownedPaths.add(path);
899
+ mergeOwnStats(ownerTracker.ownStats, source.ownStats);
900
+ mergeTrackedContent(ownerTracker, source);
818
901
  }
819
902
  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).
903
+ // A worker that finishes after its Lead turn sealed (or while it seals)
904
+ // re-seals that turn's change record (its edits are part of the turn's net
905
+ // worktree diff).
822
906
  if (ownerTracker && ownerTracker !== tracker && ownerTracker.ownerGeneration === handle.generation) {
823
- resealTurn(clean(handle.ownerSessionId), ownerTracker);
907
+ resealTurn(ownerSessionId, ownerTracker);
824
908
  }
825
909
  return true;
826
910
  } finally {
@@ -839,7 +923,7 @@ export function completeAgentTurnReview(handle, patches = []) {
839
923
  async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
840
924
  const snapshot = tracker?.worktreeSnapshot;
841
925
  if (!ownerSessionId || tracker.discarded || !snapshot?.baselineTree || !snapshot.root) return;
842
- const owned = [...sessionAttributedPaths(snapshot.root, tracker)];
926
+ const owned = [...ownedPathsUnder(snapshot.root, tracker.ownedPaths)];
843
927
  if (owned.length === 0) return;
844
928
  try {
845
929
  await saveTurnSnapshotRecord(ownerSessionId, {
@@ -868,10 +952,11 @@ function attributedAgents(ownerSessionId, tracker) {
868
952
  }));
869
953
  }
870
954
 
871
- /** Path of a tracked entry relative to `root`, or '' outside it. */
955
+ /** Path of a tracked entry relative to `root`, or '' outside it (or without
956
+ * a root). */
872
957
  function trackedRelativePath(root, entry) {
873
958
  const value = clean(entry?.path) || clean(entry?.displayPath);
874
- if (!value) return '';
959
+ if (!root || !value) return '';
875
960
  const rel = relative(root, isAbsolute(value) ? resolve(value) : resolve(root, value));
876
961
  if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return '';
877
962
  return rel.replace(/\\/g, '/');
@@ -879,42 +964,79 @@ function trackedRelativePath(root, entry) {
879
964
 
880
965
  /** A record file for an edit the worktree root cannot replay: outside it, or
881
966
  * 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 });
967
+ function untrackableRecordFile(edit, root, budget) {
968
+ const path = viewPath(root, edit.end);
969
+ const oldPath = viewPath(root, edit.start);
970
+ const named = edit.omitted
971
+ ? []
972
+ : edit.steps.map(({ before, after }) =>
973
+ namedChangePatch({ before: before.content, after: after.content, oldName: path, newName: path, budget })
974
+ );
975
+ const viewPatch = named.map((step) => step.patch).join('');
890
976
  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 } : {}),
977
+ path,
978
+ ...(oldPath !== path ? { oldPath } : {}),
979
+ status: edit.status,
980
+ additions: named.reduce((sum, step) => sum + step.additions, 0),
981
+ deletions: named.reduce((sum, step) => sum + step.deletions, 0),
982
+ ...(viewPatch ? { viewPatch } : {}),
983
+ ...(edit.omitted ? { binary: true } : {}),
898
984
  revertable: false,
899
- reason: omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
985
+ reason: edit.omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
986
+ };
987
+ }
988
+
989
+ /** The root a tracker's own patches are relative to, and a getter for the
990
+ * object format `git apply` hashes their binary content with there (one Git
991
+ * call, made only when asked). */
992
+ function trackerPatchRoot(tracker, fallback = '') {
993
+ const git = Boolean(clean(tracker?.worktreeSnapshot?.root));
994
+ const root = git ? tracker.worktreeSnapshot.root : clean(tracker?.worktreeRoot) || clean(fallback);
995
+ let format = null;
996
+ const objectFormat = () => {
997
+ format ||= git ? repositoryObjectFormat(root) : Promise.resolve('sha1');
998
+ return format;
999
+ };
1000
+ return { root: root ? resolve(root) : '', kind: git ? 'git' : 'tracked', objectFormat };
1001
+ }
1002
+
1003
+ /** One chain's edit (chainEdit) as a record file: one replayable patch
1004
+ * section per own segment, oldest first, or a view-only entry when it lies
1005
+ * outside the root or its bytes were never captured. `objectFormat` hashes a
1006
+ * binary patch, `budget` bounds the line diff. */
1007
+ function editChangeFile(edit, root, objectFormat, budget) {
1008
+ if (edit.omitted || !edit.inside) return untrackableRecordFile(edit, root, budget);
1009
+ const sections = edit.steps.map(({ before, after, oldPath, newPath }) =>
1010
+ trackedChangeFile({ oldPath, newPath, before: before.content, after: after.content, objectFormat, budget })
1011
+ );
1012
+ if (sections.length === 1) return sections[0];
1013
+ const path = trackedRelativePath(root, edit.end);
1014
+ const oldPath = trackedRelativePath(root, edit.start);
1015
+ const base = {
1016
+ path,
1017
+ ...(oldPath !== path ? { oldPath } : {}),
1018
+ status: edit.status,
1019
+ additions: sections.reduce((sum, section) => sum + section.additions, 0),
1020
+ deletions: sections.reduce((sum, section) => sum + section.deletions, 0),
900
1021
  };
1022
+ const blocked = sections.find((section) => section.revertable !== true);
1023
+ if (blocked) return { ...base, revertable: false, reason: blocked.reason };
1024
+ return { ...base, revertable: true, patch: Buffer.concat(sections.map((section) => section.patch)) };
901
1025
  }
902
1026
 
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) };
1027
+ /** The object format the binary patches of `edits` hash with: resolved (one
1028
+ * Git call) only when one of them replays binary content. */
1029
+ async function editsObjectFormat(edits, patchRoot) {
1030
+ const binary = edits.some((edit) => edit.binary && edit.inside && !edit.omitted);
1031
+ return binary ? await patchRoot.objectFormat() : 'sha1';
912
1032
  }
913
1033
 
914
- /** Record files of a non-Git turn, from the exact apply_patch buffers. */
915
- function trackedRecordFiles(tracker, root) {
1034
+ /** Record files of the tracker's own changes. `outside` are those beyond the
1035
+ * root (view-only beside a Git turn's files). */
1036
+ async function ownRecordFiles(tracker, patchRoot) {
1037
+ const { root } = patchRoot;
916
1038
  if (!tracker.valid) {
917
- return [...ownedPathsUnder(root, tracker.ownedPaths)].map((path) => ({
1039
+ const files = [...ownedPathsUnder(root, tracker.ownedPaths)].map((path) => ({
918
1040
  path,
919
1041
  status: 'M',
920
1042
  additions: 0,
@@ -922,63 +1044,41 @@ function trackedRecordFiles(tracker, root) {
922
1044
  revertable: false,
923
1045
  reason: 'file content exceeded the undo tracking limit',
924
1046
  }));
1047
+ return { files, outside: [] };
925
1048
  }
1049
+ const edits = [...tracker.chains.values()].map((chain) => chainEdit(chain, root)).filter(Boolean);
1050
+ const objectFormat = await editsObjectFormat(edits, patchRoot);
1051
+ // One line-diff budget for the whole seal pass.
1052
+ const budget = createLineDiffBudget();
926
1053
  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;
1054
+ const outside = [];
1055
+ for (const edit of edits) (edit.inside ? files : outside).push(editChangeFile(edit, root, objectFormat, budget));
1056
+ return { files, outside };
953
1057
  }
954
1058
 
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. */
1059
+ /** Freeze the turn's own changes into its durable change record: per file,
1060
+ * the session's own segments (its completed workers' included) as one
1061
+ * replayable patch, never the worktree's net diff, counted by the summed
1062
+ * deltas of those writes (`counts: 'own'`; older records without the marker
1063
+ * carry net worktree patches and counts). */
959
1064
  async function freezeTurnChanges(ownerSessionId, tracker, agents) {
960
1065
  const checkpointId = clean(tracker.checkpointId);
961
1066
  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;
1067
+ const patchRoot = trackerPatchRoot(tracker);
1068
+ if (!patchRoot.root) return null;
1069
+ const { files, outside } = await ownRecordFiles(tracker, patchRoot);
1070
+ const git = patchRoot.kind === 'git';
1071
+ const counted = ownCountIndex(tracker, patchRoot.root);
975
1072
  const record = await upsertTurnChangeRecord(ownerSessionId, {
976
1073
  checkpointId,
977
- kind: frozen.kind,
978
- root: frozen.root,
1074
+ kind: patchRoot.kind,
1075
+ root: patchRoot.root,
979
1076
  agents,
980
- files: frozen.files.map(storedChangeFile),
981
- outside: (frozen.outside || []).map(storedChangeFile),
1077
+ counts: 'own',
1078
+ // A non-Git turn has no view-only side list: an edit it cannot replay
1079
+ // blocks its Undo like before.
1080
+ files: withOwnCounts(git ? files : [...files, ...outside], counted).map(storedChangeFile),
1081
+ outside: git ? withOwnCounts(outside, counted).map(storedChangeFile) : [],
982
1082
  });
983
1083
  // A fresh freeze measured the disk itself; nothing about it is unknown.
984
1084
  _unknownChangeStates.delete(pendingFreezeKey(ownerSessionId, checkpointId));
@@ -992,13 +1092,14 @@ const pendingFreezeKey = (ownerSessionId, checkpointId) => `${ownerSessionId}\0$
992
1092
  * baseline and freeze the change record. Single-flight per tracker; a seal
993
1093
  * requested while another runs waits for it and then seals again only if a
994
1094
  * completion wake reopened the turn meanwhile. `reseal` (a late worker) only
995
- * re-freezes a turn that is still sealed.
1095
+ * re-freezes a turn that is still sealed, after any seal in flight: that one
1096
+ * captured its agents before the worker was attributed.
996
1097
  */
997
1098
  function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
998
1099
  // Its history was deleted (clearSessionChangeHistory): never written again.
999
1100
  if (tracker.discarded) return Promise.resolve();
1000
1101
  if (tracker.freezing) {
1001
- if (reseal) return tracker.freezing;
1102
+ if (reseal) return tracker.freezing.then(() => sealTurn(ownerSessionId, tracker, { reseal: true }));
1002
1103
  return tracker.freezing.then(() => (tracker.sealed ? undefined : sealTurn(ownerSessionId, tracker)));
1003
1104
  }
1004
1105
  const epoch = tracker.sealEpoch;
@@ -1014,6 +1115,8 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1014
1115
  }
1015
1116
  }
1016
1117
  if (tracker.discarded || tracker.sealEpoch !== epoch || (reseal && !tracker.sealed)) return;
1118
+ // A re-seal never rewrites a turn once the next one opened.
1119
+ if (reseal && turnSuperseded(ownerSessionId, tracker)) return;
1017
1120
  tracker.sealed = true;
1018
1121
  await persistTurnSnapshotRecord(ownerSessionId, tracker);
1019
1122
  try {
@@ -1021,11 +1124,8 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1021
1124
  } catch {
1022
1125
  /* the change record adds Undo history; it never fails a turn */
1023
1126
  }
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);
1127
+ // The bounded own before/after content stays until the next turn: a late
1128
+ // worker's re-seal and an Undo/Reapply of this turn fold into it.
1029
1129
  })().finally(() => {
1030
1130
  if (tracker.freezing === run) tracker.freezing = null;
1031
1131
  const count = (_pendingFreezes.get(key) || 1) - 1;
@@ -1037,7 +1137,9 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1037
1137
  }
1038
1138
 
1039
1139
  function resealTurn(ownerSessionId, tracker) {
1040
- if (!tracker.sealed || tracker.freezing || !clean(tracker.checkpointId) || tracker.resealQueued) return;
1140
+ // A turn still running seals with every published agent; one sealed or
1141
+ // sealing queues its re-seal (sealTurn runs it after a seal in flight).
1142
+ if (!(tracker.sealed || tracker.freezing) || !clean(tracker.checkpointId) || tracker.resealQueued) return;
1041
1143
  // On the session lock: an in-flight Undo/Reapply lands before the re-freeze
1042
1144
  // reads the worktree. A turn start queued ahead of it runs it before opening
1043
1145
  // (beginTurnSnapshot), so one queued re-seal covers every late worker.
@@ -1045,10 +1147,17 @@ function resealTurn(ownerSessionId, tracker) {
1045
1147
  void serializeChangeMutation(ownerSessionId, () => runQueuedReseal(ownerSessionId, tracker)).catch(() => {});
1046
1148
  }
1047
1149
 
1150
+ /** True once a newer turn of the session opened over `tracker`'s. */
1151
+ function turnSuperseded(ownerSessionId, tracker) {
1152
+ const turn = _turnsBySession.get(ownerSessionId);
1153
+ return Boolean(turn) && turn.generation !== tracker.ownerGeneration;
1154
+ }
1155
+
1048
1156
  /** Run `tracker`'s queued re-seal once, whichever lock step reaches it first. */
1049
1157
  function runQueuedReseal(ownerSessionId, tracker) {
1050
1158
  if (!tracker.resealQueued) return undefined;
1051
1159
  tracker.resealQueued = false;
1160
+ if (turnSuperseded(ownerSessionId, tracker)) return undefined;
1052
1161
  return sealTurn(ownerSessionId, tracker, { reseal: true });
1053
1162
  }
1054
1163
 
@@ -1065,92 +1174,128 @@ export async function completeTurnSnapshot(sessionId, expected = null) {
1065
1174
  return true;
1066
1175
  }
1067
1176
 
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
- */
1177
+ /** Is this turn's review labelled with its uncontended Git baseline? (The
1178
+ * review itself is always the tracker's own segments.) */
1074
1179
  function worktreeSnapshotUsable(tracker) {
1075
1180
  return Boolean(tracker && !tracker.worktreeContended && tracker.worktreeSnapshot);
1076
1181
  }
1077
1182
 
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
- }
1183
+ /** The latest turn's review once its tracker is gone (the next turn replaced
1184
+ * it, the cache evicted it, the runtime restarted): its change record's
1185
+ * files still applied, with their own counts and recorded patches — never a
1186
+ * worktree diff, so a foreign edit inside an own file stays out. */
1187
+ async function recordedTurnReview(ownerSessionId, { omitPatch }) {
1188
+ const record = await latestTurnRecord(ownerSessionId);
1102
1189
  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);
1190
+ const files = (record.files || []).filter((file) => fileState(file) === 'applied');
1191
+ const patches = files.map(changeDisplayPatch);
1192
+ const rows = files.map((file, index) => {
1193
+ const row = {
1194
+ path: file.path,
1195
+ ...(file.oldPath ? { oldPath: file.oldPath } : {}),
1196
+ status: file.status,
1197
+ additions: Number(file.additions) || 0,
1198
+ deletions: Number(file.deletions) || 0,
1199
+ ...(file.binary ? { binary: true } : {}),
1200
+ };
1201
+ // The record dropped its patch for size: still listed too large.
1202
+ return recordPatchOmitted(file, patches[index]) ? patchOmittedFile(row) : row;
1203
+ });
1204
+ const shaped = omitPatch ? { files: rows, patch: '' } : perFileReview(rows, patches.join(''));
1119
1205
  return {
1120
1206
  supported: true,
1121
- files: resumed.snapshot.files || [],
1122
- patch: resumed.snapshot.patch || '',
1123
- ...(resumed.snapshot.patchOmitted ? { patchOmitted: true } : {}),
1207
+ files: shaped.files,
1208
+ patch: shaped.patch,
1209
+ ...(omitPatch ? { patchOmitted: true } : {}),
1210
+ ...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
1124
1211
  snapshotKind: 'scoped',
1125
1212
  revertMode: 'scoped',
1126
- checkpointId: clean(resumed.record.checkpointId),
1127
- patchTruncated: resumed.snapshot.patchTruncated === true,
1213
+ checkpointId: clean(record.checkpointId),
1214
+ patchTruncated: false,
1128
1215
  authoritative: true,
1129
1216
  agents: publicAgentReviews(ownerSessionId),
1130
1217
  reason: 'recorded',
1131
1218
  };
1132
1219
  }
1133
1220
 
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
- };
1221
+ /** A record file kept without its patch for size. */
1222
+ const recordPatchOmitted = (file, patch) => file?.patchOmitted === true && !patch;
1223
+
1224
+ /** A review reply's files and patch under the per-file caps: a file whose
1225
+ * sections were cut for display, or pass the per-file cap or the total left,
1226
+ * is listed with its counts but without its patch (read on its own when it
1227
+ * is opened). `diffTooLarge` once nothing fit. */
1228
+ function perFileReview(files, patch) {
1229
+ const sections = new Map();
1230
+ for (const section of splitPatchSections(Buffer.from(patch || '', 'utf8'))) {
1231
+ const key = pathKey(section.newPath ?? section.oldPath ?? '');
1232
+ const entry = sections.get(key) || { parts: [], bytes: 0, cut: false };
1233
+ entry.parts.push(section.patch);
1234
+ entry.bytes += section.patch.length;
1235
+ entry.cut ||= reviewSectionTruncated(section.patch);
1236
+ sections.set(key, entry);
1237
+ }
1238
+ const budget = createReviewPatchBudget();
1239
+ const omitted = new Set();
1240
+ const kept = [];
1241
+ for (const [key, entry] of sections) {
1242
+ if (!entry.cut && budget.admit(entry.bytes)) kept.push(...entry.parts);
1243
+ else omitted.add(key);
1244
+ }
1245
+ const shaped = files.map((file) => (omitted.has(pathKey(file.path)) ? patchOmittedFile(file) : file));
1246
+ const text = Buffer.concat(kept).toString('utf8');
1247
+ return { files: shaped, patch: text, diffTooLarge: reviewDiffTooLarge(shaped, text) };
1248
+ }
1249
+
1250
+ /** The latest turn's change record, its unrecordable file states applied. */
1251
+ async function latestTurnRecord(ownerSessionId) {
1252
+ const checkpointId = await latestRecordedCheckpoint(ownerSessionId);
1253
+ const stored = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId).catch(() => null) : null;
1254
+ return withUnknownChangeStates(ownerSessionId, stored);
1255
+ }
1256
+
1257
+ /** One file's whole display patch in the tracker's view, rendered on its own
1258
+ * instead of within the reply's caps; '' when the view lists no such file. */
1259
+ function trackerFilePatch(tracker, path) {
1260
+ const { root, kind } = trackerPatchRoot(tracker);
1261
+ const key = pathKey(path);
1262
+ const budget = createLineDiffBudget();
1263
+ for (const chain of tracker.chains.values()) {
1264
+ const edit = chainEdit(chain, root);
1265
+ if (!edit || (kind === 'git' && !edit.inside) || pathKey(viewPath(root, edit.end)) !== key) continue;
1266
+ return edit.steps.map((step) => renderStep(step, root, budget).text).join('');
1267
+ }
1268
+ return '';
1269
+ }
1270
+
1271
+ /** One file of the latest turn's review with its whole patch, up to the
1272
+ * file-patch read's cap: the file a reply listed `patchOmitted`. */
1273
+ export async function getTurnReviewFilePatch(_worktree, sessionId, file) {
1274
+ const path = clean(file).replace(/\\/g, '/');
1275
+ if (DISABLED) return { supported: false, path, patch: '' };
1276
+ const ownerSessionId = clean(sessionId);
1277
+ if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
1278
+ const tracker = _diffTrackersBySession.get(ownerSessionId);
1279
+ if (tracker) return filePatchReply(path, trackerFilePatch(tracker, path));
1280
+ // Only a file the recorded review lists, by its own path.
1281
+ const record = await latestTurnRecord(ownerSessionId);
1282
+ const match = record ? findChangeFile(record, path) : null;
1283
+ if (
1284
+ !match ||
1285
+ fileState(match) !== 'applied' ||
1286
+ changePathKey(match.path) !== changePathKey(relativeRecordPath(record, path))
1287
+ ) {
1288
+ return filePatchReply(path, '');
1289
+ }
1290
+ const patch = changeDisplayPatch(match);
1291
+ if (recordPatchOmitted(match, patch)) return { ...filePatchReply(path, ''), ...patchOmittedFile({}) };
1292
+ return filePatchReply(path, patch);
1293
+ }
1294
+
1295
+ /** `value` as findChangeFile reads it: relative to the record's root. */
1296
+ function relativeRecordPath(record, value) {
1297
+ const target = clean(value).replace(/\\/g, '/');
1298
+ return isAbsolute(target) && record?.root ? relative(record.root, resolve(target)).replace(/\\/g, '/') : target;
1154
1299
  }
1155
1300
 
1156
1301
  function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
@@ -1163,69 +1308,38 @@ function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
1163
1308
  }
1164
1309
  }
1165
1310
 
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. */
1311
+ /** Return the Lead turn's own-file diff plus attributed child reviews, and
1312
+ * `historyTurns`: the retained turn records clearSessionChangeHistory deletes. */
1181
1313
  export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
1182
- if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
1314
+ if (DISABLED) return { supported: false, files: [], patch: '', agents: [], historyTurns: 0 };
1183
1315
  const ownerSessionId = clean(sessionId);
1316
+ const review = await readTurnReview(ownerSessionId, options);
1317
+ const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
1318
+ return { ...review, historyTurns: records.length };
1319
+ }
1320
+
1321
+ async function readTurnReview(ownerSessionId, options) {
1184
1322
  const turn = _turnsBySession.get(ownerSessionId);
1185
1323
  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 });
1324
+ if (!tracker) {
1325
+ const recorded = await recordedTurnReview(ownerSessionId, { omitPatch: options.summary === true });
1216
1326
  if (recorded) return recorded;
1217
1327
  }
1328
+ const view = tracker ? trackerView(tracker) : null;
1329
+ const worktree = worktreeSnapshotUsable(tracker);
1218
1330
  let revertMode = '';
1219
- if (snapshot) revertMode = 'worktree';
1220
- else if (trackedRevertAvailable) revertMode = 'tracked';
1331
+ if (worktree) revertMode = 'worktree';
1332
+ else if (tracker?.valid && view.files.length) revertMode = 'tracked';
1333
+ const shaped = perFileReview(view?.files || [], view?.patch || '');
1221
1334
  return {
1222
1335
  supported: true,
1223
- files: snapshot?.files || [],
1224
- patch: snapshot?.patch ?? tracker?.unifiedDiff ?? '',
1225
- snapshotKind: snapshot ? 'worktree' : 'tool',
1336
+ files: shaped.files,
1337
+ patch: shaped.patch,
1338
+ ...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
1339
+ snapshotKind: worktree ? 'worktree' : 'tool',
1226
1340
  revertMode,
1227
1341
  checkpointId: clean(turn?.checkpointId || tracker?.checkpointId),
1228
- patchTruncated: (snapshot || tracker)?.patchTruncated === true,
1342
+ patchTruncated: false,
1229
1343
  authoritative: Boolean(turn && tracker),
1230
1344
  agents: publicAgentReviews(ownerSessionId),
1231
1345
  ...(turn ? { generation: turn.generation } : { reason: 'no-turn' }),
@@ -1259,16 +1373,83 @@ export async function getSessionReviewDiff(worktree, sessionId) {
1259
1373
  // session recorded before baselines existed) come from them.
1260
1374
  loadTurnChangeRecords(ownerSessionId).catch(() => []),
1261
1375
  ]);
1262
- const merged = await sessionChangeReview(review, { baselines, records });
1263
- // `historyTurns`: the retained turn records clearSessionChangeHistory deletes.
1264
- return { ...merged, historyTurns: records.length };
1376
+ return await sessionChangeReview(review, { baselines, records });
1265
1377
  }
1266
1378
 
1267
- async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1379
+ /** What the session review diffs inside a Git root: the stored scope (or
1380
+ * live tracker) of this worktree, the paths the session owns there, and its
1381
+ * earliest baseline tree with the recorded first states that complete it. */
1382
+ async function sessionReviewScope(worktree, ownerSessionId) {
1268
1383
  const tracker = _diffTrackersBySession.get(ownerSessionId);
1269
1384
  const scopes = await loadSessionSnapshotRecords(ownerSessionId).catch(() => []);
1270
1385
  const stored = scopes.find((scope) => scopeMatchesWorktree(scope, worktree, tracker));
1271
1386
  const root = stored?.root || tracker?.worktreeSnapshot?.root || clean(worktree);
1387
+ const ownedPaths = new Set(stored?.toolFiles || []);
1388
+ for (const path of ownedPathsUnder(root, tracker?.ownedPaths)) ownedPaths.add(path);
1389
+ const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
1390
+ const baselineFiles = new Map((stored?.baselineFiles || []).map((entry) => [pathKey(entry.path), entry]));
1391
+ const storedPaths = new Set((stored?.toolFiles || []).map(pathKey));
1392
+ for (const entry of tracker?.worktreeSnapshot?.baselineFiles.values() || []) {
1393
+ if (!storedPaths.has(pathKey(entry.path))) baselineFiles.set(pathKey(entry.path), entry);
1394
+ }
1395
+ return { tracker, stored, root, ownedPaths, baselineTree, baselineFiles: [...baselineFiles.values()] };
1396
+ }
1397
+
1398
+ /** `value` (absolute, or relative to `root`) as a path relative to `root`;
1399
+ * null outside it. */
1400
+ function relativeInside(root, value) {
1401
+ const target = clean(value);
1402
+ if (!root || !target) return null;
1403
+ const rel = relative(resolve(root), isAbsolute(target) ? resolve(target) : resolve(root, target));
1404
+ if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return null;
1405
+ return rel.replace(/\\/g, '/');
1406
+ }
1407
+
1408
+ /** One file of the session review with its whole patch, up to the file-patch
1409
+ * read's cap: the file a reply listed `patchOmitted`. `file` is the listed
1410
+ * file's `id` or path, `oldPath` a rename's source. Inside the Git root it is
1411
+ * the session's baseline tree against the worktree now for that path alone;
1412
+ * elsewhere its first-seen state measured against the disk now. */
1413
+ export async function getSessionReviewFilePatch(worktree, sessionId, file, oldPath = '') {
1414
+ const path = clean(file).replace(/\\/g, '/');
1415
+ if (DISABLED) return { supported: false, path, patch: '' };
1416
+ const ownerSessionId = clean(sessionId);
1417
+ if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
1418
+ const scope = await sessionReviewScope(worktree, ownerSessionId);
1419
+ const rel = scope.baselineTree ? relativeInside(scope.root, path) : null;
1420
+ if (rel !== null) {
1421
+ // Each target is authorized on its own: an unowned file is never read,
1422
+ // an unowned rename source never joins its diff.
1423
+ const owned = new Set([...scope.ownedPaths].map(pathKey));
1424
+ if (!owned.has(pathKey(rel))) return { supported: true, path, patch: '' };
1425
+ const source = relativeInside(scope.root, oldPath) || '';
1426
+ const oldRel = source && owned.has(pathKey(source)) ? source : '';
1427
+ const result = await resumeTurnWorktreeFilePatch(scope.root, scope.baselineTree, {
1428
+ paths: [...scope.ownedPaths],
1429
+ baselineFiles: scope.baselineFiles,
1430
+ path: rel,
1431
+ oldPath: oldRel,
1432
+ maxBytes: REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
1433
+ }).catch(() => null);
1434
+ if (result) return { supported: true, path, ...result };
1435
+ }
1436
+ const [base, baselines, records] = await Promise.all([
1437
+ // Beside a Git baseline a file outside its root is measured from its
1438
+ // first state; the Git side's own files need no diff for that.
1439
+ rel === null && scope.baselineTree && scope.root
1440
+ ? { supported: true, root: scope.root, files: [], patch: '', source: 'git' }
1441
+ : baselineSessionReviewDiff(worktree, ownerSessionId),
1442
+ loadSessionBaselines(ownerSessionId).catch(() => null),
1443
+ loadTurnChangeRecords(ownerSessionId).catch(() => []),
1444
+ ]);
1445
+ return await sessionChangeReview(base, { baselines, records, file: path });
1446
+ }
1447
+
1448
+ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1449
+ const { tracker, stored, root, ownedPaths, baselineTree, baselineFiles } = await sessionReviewScope(
1450
+ worktree,
1451
+ ownerSessionId
1452
+ );
1272
1453
  // A running turn is still mutating the worktree: shell, office and script
1273
1454
  // edits only reach the checkpoint diff once it is refreshed, exactly as the
1274
1455
  // turn review bar does.
@@ -1279,8 +1460,6 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1279
1460
  // Best-effort refresh: the checkpoint diff uses the last captured state.
1280
1461
  }
1281
1462
  }
1282
- const ownedPaths = new Set(stored?.toolFiles || []);
1283
- for (const path of sessionAttributedPaths(root, tracker)) ownedPaths.add(path);
1284
1463
  if (ownedPaths.size === 0) {
1285
1464
  // Authoritatively empty only when the session left ownership metadata
1286
1465
  // behind (a stored scope, a live tracker, or a deleted history); with
@@ -1296,21 +1475,12 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1296
1475
  source: known ? 'empty' : 'absent',
1297
1476
  };
1298
1477
  }
1299
- const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
1300
1478
  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
1479
  const snapshot = await resumeTurnWorktreeSnapshot(root, baselineTree, {
1307
1480
  paths: [...ownedPaths],
1308
- baselineFiles: [...baselineFiles.values()],
1481
+ baselineFiles,
1309
1482
  }).catch(() => null);
1310
1483
  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
1484
  return {
1315
1485
  supported: true,
1316
1486
  root,
@@ -1320,7 +1490,6 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1320
1490
  snapshotKind: 'session',
1321
1491
  authoritative: true,
1322
1492
  source: 'git',
1323
- ...(uncommittedFiles ? { uncommittedFiles } : {}),
1324
1493
  };
1325
1494
  }
1326
1495
  }
@@ -1333,8 +1502,7 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1333
1502
  supported: true,
1334
1503
  root,
1335
1504
  files: [],
1336
- patch: tracker.unifiedDiff || '',
1337
- patchTruncated: tracker.patchTruncated === true,
1505
+ patch: '',
1338
1506
  snapshotKind: 'session-tool',
1339
1507
  authoritative: true,
1340
1508
  source: 'tool',
@@ -1354,52 +1522,108 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
1354
1522
  };
1355
1523
  }
1356
1524
 
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
- }
1525
+ const UNATTRIBUTED_FILE = 'turn review revert cannot attribute this file to the session';
1363
1526
 
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) {
1527
+ /** Reverse-apply the latest turn's own changes — every file its own tools
1528
+ * wrote (`file` null) or the own file the user picked. A sealed turn undoes
1529
+ * its change record (state included); a running one its tracker's own
1530
+ * segments. Resolves to the turn's checkpoint, the absolute paths touched and
1531
+ * whether the record already carries the new state. */
1532
+ async function revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId) {
1369
1533
  const tracker = _diffTrackersBySession.get(ownerSessionId);
1370
1534
  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));
1535
+ if (!tracker || tracker.sealed) {
1536
+ const checkpointId = clean(tracker?.checkpointId) || (await latestRecordedCheckpoint(ownerSessionId));
1537
+ if (!tracker) assertCheckpointMatches(expectedCheckpointId, checkpointId);
1538
+ const record = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId) : null;
1539
+ if (record) return await revertRecordedTurn(ownerSessionId, withUnknownChangeStates(ownerSessionId, record), file);
1540
+ if (!tracker) throw new Error('turn worktree snapshot is unavailable');
1541
+ }
1542
+ const paths = await revertTrackedTurn(tracker, worktree, file);
1543
+ return { checkpointId: clean(tracker.checkpointId), paths, recorded: false };
1544
+ }
1545
+
1546
+ /** The latest turn on record: the stored Git review's, else the newest change record's. */
1547
+ async function latestRecordedCheckpoint(ownerSessionId) {
1548
+ const snapshot = await loadTurnSnapshotRecord(ownerSessionId).catch(() => null);
1549
+ if (clean(snapshot?.checkpointId)) return clean(snapshot.checkpointId);
1550
+ const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
1551
+ return clean(records.at(-1)?.checkpointId);
1552
+ }
1553
+
1554
+ const recordFilePaths = (root, files) =>
1555
+ files
1556
+ .flatMap((entry) => [entry.path, entry.oldPath])
1557
+ .filter(Boolean)
1558
+ .map((path) => resolve(root, path));
1559
+
1560
+ /** Undo a sealed turn's applied record files (all, or the one named). */
1561
+ async function revertRecordedTurn(ownerSessionId, record, file) {
1562
+ let targets;
1563
+ if (file) {
1564
+ const match = findChangeFile(record, file);
1565
+ if (!match) throw new Error(UNATTRIBUTED_FILE);
1566
+ if (fileState(match) !== 'applied') throw new Error(`${match.path} is already undone`);
1567
+ targets = [match];
1568
+ } else {
1569
+ targets = (record.files || []).filter((entry) => fileState(entry) === 'applied');
1570
+ }
1571
+ if (targets.length > 0) await mutateRecordFiles(ownerSessionId, record, targets, true);
1572
+ return { checkpointId: clean(record.checkpointId), paths: recordFilePaths(record.root, targets), recorded: true };
1573
+ }
1574
+
1575
+ /** Undo a running turn's own segments (every file, or the one matching
1576
+ * `file`), newest first. */
1577
+ async function revertTrackedTurn(tracker, worktree, file) {
1578
+ if (!tracker.valid) throw new Error('turn review tracked file snapshot is unavailable');
1579
+ const patchRoot = trackerPatchRoot(tracker, resolve(clean(worktree) || '.'));
1580
+ const git = patchRoot.kind === 'git';
1581
+ let targets = [...tracker.chains.values()]
1582
+ .map((chain) => ({ chain, edit: chainEdit(chain, patchRoot.root) }))
1583
+ // Beside a Git root, an edit outside it is view-only, never undone.
1584
+ .filter(({ edit }) => edit && (!git || edit.inside));
1585
+ if (file) {
1586
+ const target = targets.find(({ chain }) => chainMatchesPath(chain, patchRoot.root, file));
1587
+ if (!target) throw new Error(UNATTRIBUTED_FILE);
1588
+ targets = [target];
1387
1589
  }
1388
- if (tracker?.worktreeSnapshot) {
1389
- return restored(
1390
- whole
1391
- ? await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot)
1392
- : await revertTurnWorktreeFile(tracker.worktreeSnapshot, file)
1590
+ if (targets.length === 0) return [];
1591
+ for (const { edit } of targets) {
1592
+ const omitted = edit.steps.flatMap(({ before, after }) => [before.content, after.content]).find(isOmittedContent);
1593
+ if (!omitted) continue;
1594
+ throw new Error(
1595
+ `turn review revert is unavailable for ${edit.start.displayPath}: binary/large file content was not captured (${omitted.size} bytes)`
1393
1596
  );
1394
1597
  }
1395
- const recorded = await revertFromRecord(ownerSessionId, recordFile, expectedCheckpointId);
1396
- if (recorded) return recorded;
1397
- throw new Error('turn worktree snapshot is unavailable');
1598
+ const edits = targets.map(({ edit }) => edit);
1599
+ const objectFormat = await editsObjectFormat(edits, patchRoot);
1600
+ const budget = createLineDiffBudget();
1601
+ const files = edits.map((edit) => storedChangeFile(editChangeFile(edit, patchRoot.root, objectFormat, budget)));
1602
+ const blocked = files.find((entry) => entry.revertable !== true);
1603
+ if (blocked) throw new Error(`turn review revert is unavailable for ${blocked.path}: ${blocked.reason}`);
1604
+ await applyTurnChangeFiles({ root: patchRoot.root, kind: patchRoot.kind, counts: 'own' }, files, {
1605
+ reverse: true,
1606
+ });
1607
+ undoTrackedChains(
1608
+ tracker,
1609
+ targets.map(({ chain }) => chain)
1610
+ );
1611
+ const paths = recordFilePaths(patchRoot.root, files);
1612
+ noteTurnWorktreeChange(paths);
1613
+ if (tracker.worktreeSnapshot) {
1614
+ try {
1615
+ await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
1616
+ } catch {
1617
+ // Best-effort refresh: the next review read captures afresh.
1618
+ }
1619
+ }
1620
+ return paths;
1398
1621
  }
1399
1622
 
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. */
1623
+ /** Record a running turn's composer Undo in the record an earlier seal of the
1624
+ * same turn left (a completion wake reopened it): a record file whose every
1625
+ * side (a rename's old path too) was undone is `reverted`; every other file
1626
+ * keeps its state. */
1403
1627
  async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
1404
1628
  if (!ownerSessionId || !checkpointId) return;
1405
1629
  const record = await loadTurnChangeRecord(ownerSessionId, checkpointId);
@@ -1425,26 +1649,28 @@ async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
1425
1649
  /** Composer Undo of the latest turn, on the per-session change lock shared
1426
1650
  * with historical Undo/Reapply, recorded in the turn's change record so the
1427
1651
  * historical view offers Reapply rather than a second Undo. */
1428
- function revertLatestTurn(worktree, sessionId, file, whole, expectedCheckpointId) {
1652
+ function revertLatestTurn(worktree, sessionId, file, expectedCheckpointId) {
1429
1653
  const ownerSessionId = clean(sessionId);
1430
1654
  return serializeChangeMutation(ownerSessionId, async () => {
1431
- // A seal still freezing would record the pre-restore disk after this.
1655
+ // A seal still freezing would record the pre-restore state after this.
1432
1656
  const freezing = _diffTrackersBySession.get(ownerSessionId)?.freezing;
1433
1657
  if (freezing) await freezing.catch(() => {});
1434
- const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file, whole, expectedCheckpointId);
1435
- await recordComposerRevert(ownerSessionId, restored);
1658
+ const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId);
1659
+ if (!restored.recorded) await recordComposerRevert(ownerSessionId, restored);
1436
1660
  return await getTurnReviewDiff(worktree, ownerSessionId);
1437
1661
  });
1438
1662
  }
1439
1663
 
1440
- /** Restore one reviewed file to this turn's worktree baseline, never to HEAD. */
1664
+ /** Undo this turn's own changes to one reviewed file (never a restore to HEAD
1665
+ * or to the turn-start bytes: foreign edits outside its hunks stay). */
1441
1666
  export function revertTurnReviewFile(worktree, sessionId, file, expectedCheckpointId = '') {
1442
- return revertLatestTurn(worktree, sessionId, file, false, expectedCheckpointId);
1667
+ if (!clean(file)) return Promise.reject(new TypeError('turn review file path is required'));
1668
+ return revertLatestTurn(worktree, sessionId, file, expectedCheckpointId);
1443
1669
  }
1444
1670
 
1445
- /** Restore every file in the reviewed turn to its turn-start state. */
1671
+ /** Undo this turn's own changes to every file its tools wrote. */
1446
1672
  export function revertTurnReview(worktree, sessionId, expectedCheckpointId = '') {
1447
- return revertLatestTurn(worktree, sessionId, null, true, expectedCheckpointId);
1673
+ return revertLatestTurn(worktree, sessionId, null, expectedCheckpointId);
1448
1674
  }
1449
1675
 
1450
1676
  // ── Historical turn changes (per-turn Undo/Reapply) ─────────────────────────
@@ -1483,9 +1709,10 @@ export async function getTurnChanges(sessionId, options = {}) {
1483
1709
  // An old record's outside-root file kept no counts: measure it so the card
1484
1710
  // agrees with the session review and the island.
1485
1711
  if (record?.files?.length) {
1712
+ const budget = createLineDiffBudget();
1486
1713
  record = {
1487
1714
  ...record,
1488
- files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root))),
1715
+ files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root, budget))),
1489
1716
  };
1490
1717
  }
1491
1718
  let status = 'missing';
@@ -1553,88 +1780,68 @@ function withUnknownChangeStates(ownerSessionId, record) {
1553
1780
  return keys ? { ...record, files: markUnknownStates(record.files, keys) } : record;
1554
1781
  }
1555
1782
 
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) {
1783
+ /** Bring the latest-turn review (composer bar, session Diff) in line with an
1784
+ * Undo/Reapply that just landed: signal the moved paths, fold the record's
1785
+ * own chains out of (or back into) the turn's tracker when it is that turn,
1786
+ * re-capture the Git snapshot (sealed ones too) and persist its
1787
+ * attribution. Best-effort: the mutation itself already landed. */
1788
+ async function refreshLatestTurnReview(ownerSessionId, record, targets, reverse) {
1789
+ noteTurnWorktreeChange(recordFilePaths(record.root, targets));
1561
1790
  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);
1791
+ if (!tracker) return;
1792
+ if (tracker.valid && clean(tracker.checkpointId) === clean(record.checkpointId)) {
1793
+ const root = resolve(record.root);
1794
+ if (reverse) {
1795
+ const matches = (chain) =>
1796
+ targets.some((file) => [file.path, file.oldPath].some((path) => chainMatchesPath(chain, root, path)));
1797
+ undoTrackedChains(tracker, [...tracker.chains.values()].filter(matches));
1798
+ } else {
1799
+ redoTrackedChains(tracker, root, targets);
1800
+ }
1573
1801
  }
1574
- if (moves.length === 0) return null;
1802
+ if (!tracker.worktreeSnapshot) return;
1575
1803
  try {
1576
- for (const move of moves) move.before = await currentTrackedContent(move.source);
1804
+ await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
1577
1805
  } catch {
1578
- return { tracker, moves, unreadable: true };
1806
+ // Best-effort refresh: persist the last captured state.
1579
1807
  }
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
- };
1808
+ if (tracker.sealed) await persistTurnSnapshotRecord(ownerSessionId, tracker);
1598
1809
  }
1599
1810
 
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);
1811
+ /** Reverse-apply (`reverse`) or forward-apply the record files `targets`
1812
+ * against the current disk, all-or-nothing: `git apply` checks every hunk
1813
+ * (every binary file's exact bytes) before it writes, so an overlapping
1814
+ * foreign edit fails the whole call and nothing is overwritten. Then their
1815
+ * state is recorded. Resolves to the updated record. */
1816
+ async function mutateRecordFiles(ownerSessionId, record, targets, reverse) {
1817
+ const verb = reverse ? 'undone' : 'reapplied';
1818
+ const checkpointId = clean(record.checkpointId);
1819
+ const blocked = targets.find((entry) => entry.revertable !== true);
1820
+ if (blocked) {
1821
+ throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
1614
1822
  }
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;
1823
+ await applyTurnChangeFiles(record, targets, { reverse });
1824
+ // State lands only after git applied every target; a state that cannot
1825
+ // land takes the worktree change back with it.
1826
+ const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
1827
+ let updated;
1632
1828
  try {
1633
- await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
1634
- } catch {
1635
- // Best-effort refresh: persist the last captured state.
1829
+ updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
1830
+ } catch (error) {
1831
+ try {
1832
+ await applyTurnChangeFiles(record, targets, { reverse: !reverse });
1833
+ } catch (rollbackError) {
1834
+ await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
1835
+ await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
1836
+ throw new Error(
1837
+ `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`,
1838
+ { cause: error }
1839
+ );
1840
+ }
1841
+ throw error;
1636
1842
  }
1637
- if (tracker.sealed) await persistTurnSnapshotRecord(ownerSessionId, tracker);
1843
+ await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
1844
+ return updated;
1638
1845
  }
1639
1846
 
1640
1847
  /** Undo (`reverse`) or Reapply one file or the whole turn, all-or-nothing. */
@@ -1663,32 +1870,7 @@ async function mutateTurnChanges(sessionId, checkpointIdValue, fileValue, revisi
1663
1870
  targets = (record.files || []).filter((entry) => fileState(entry) === from);
1664
1871
  if (targets.length === 0) throw new Error(`this turn has no changes to be ${verb}`);
1665
1872
  }
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);
1873
+ const updated = await mutateRecordFiles(ownerSessionId, record, targets, reverse);
1692
1874
  return turnChangesReply({
1693
1875
  status: updated ? 'ready' : 'missing',
1694
1876
  checkpointId,