mixdog 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (557) hide show
  1. package/README.md +18 -0
  2. package/package.json +2 -1
  3. package/scripts/gate-local.mjs +19 -3
  4. package/scripts/model-catalog-audit.mjs +1 -15
  5. package/scripts/provider-pingpong-live.mjs +509 -0
  6. package/src/agents/advisor/AGENT.md +1 -1
  7. package/src/agents/advisor/agent.json +1 -1
  8. package/src/agents/reviewer/AGENT.md +3 -3
  9. package/src/agents/reviewer/agent.json +1 -1
  10. package/src/defaults/shipped-history.json +6 -0
  11. package/src/defaults/skills/setup/SKILL.md +6 -1
  12. package/src/defaults/skills/setup/references/actions.md +44 -22
  13. package/src/defaults/skills/setup/references/surfaces.md +10 -8
  14. package/src/headless-exec.mjs +15 -8
  15. package/src/lib/keychain-cjs.cjs +7 -4
  16. package/src/output-styles/common.md +11 -10
  17. package/src/repl/slash-commands.mjs +16 -1
  18. package/src/rules/agent/AGENT.md +5 -5
  19. package/src/rules/lead/LEAD.md +5 -5
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  22. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +92 -61
  23. package/src/runtime/agent/orchestrator/cache-mtime.mjs +10 -1
  24. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/config.mjs +5 -0
  26. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -0
  27. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +26 -7
  28. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +112 -7
  29. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  30. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  31. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  32. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  33. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  34. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  35. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  36. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  37. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  38. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  39. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  40. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  41. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  42. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  43. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +48 -20
  44. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  45. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +98 -0
  46. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  47. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  48. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +48 -37
  49. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +10 -7
  50. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  51. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  52. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  53. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  54. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  55. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  56. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  57. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +102 -45
  58. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  59. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-blocks.mjs +20 -1
  61. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-delta.mjs +15 -2
  62. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-start.mjs +11 -4
  63. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/message-events.mjs +3 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events.mjs +12 -5
  65. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-outcome.mjs +1 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-state.mjs +14 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -1
  68. package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +9 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +40 -7
  70. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  71. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  72. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +88 -21
  73. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +30 -10
  74. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +106 -0
  75. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +10 -61
  76. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +94 -15
  77. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  78. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +117 -72
  79. package/src/runtime/agent/orchestrator/providers/compat-request-policy.mjs +78 -2
  80. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +75 -10
  81. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  83. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +103 -23
  84. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-messages.mjs +43 -2
  85. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  86. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +32 -6
  87. package/src/runtime/agent/orchestrator/providers/cursor.mjs +27 -3
  88. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +64 -1
  89. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +70 -6
  90. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +34 -7
  91. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +32 -6
  92. package/src/runtime/agent/orchestrator/providers/deepseek.mjs +67 -0
  93. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +80 -18
  94. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +11 -4
  95. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +46 -8
  96. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +273 -0
  97. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  99. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +41 -5
  100. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  101. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  102. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +369 -112
  103. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +3 -1
  104. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +140 -35
  105. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +70 -24
  106. package/src/runtime/agent/orchestrator/providers/gemini.mjs +80 -29
  107. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  108. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  109. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +69 -19
  110. package/src/runtime/agent/orchestrator/providers/lib/action-input-contract.mjs +60 -0
  111. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +1 -0
  112. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +246 -42
  113. package/src/runtime/agent/orchestrator/providers/lib/anthropic-server-tool-usage.mjs +18 -0
  114. package/src/runtime/agent/orchestrator/providers/lib/anthropic-system-blocks.mjs +27 -6
  115. package/src/runtime/agent/orchestrator/providers/lib/claude-bridge-schema.mjs +388 -0
  116. package/src/runtime/agent/orchestrator/providers/lib/inclusive-usage.mjs +54 -0
  117. package/src/runtime/agent/orchestrator/providers/lib/origin-bound-fetch.mjs +16 -0
  118. package/src/runtime/agent/orchestrator/providers/lib/responses-terminal-fields.mjs +11 -0
  119. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +8 -0
  120. package/src/runtime/agent/orchestrator/providers/lib/wire-pairing.mjs +48 -22
  121. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +119 -18
  122. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +13 -15
  123. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  124. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  125. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +1 -1
  126. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +1 -0
  127. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +33 -1
  128. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  129. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +2 -0
  130. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -0
  131. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +123 -61
  132. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +35 -14
  133. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +27 -2
  134. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +37 -7
  135. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/tool-call-acc.mjs +2 -9
  136. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  137. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  138. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +47 -2
  139. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +47 -25
  140. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +5 -1
  141. package/src/runtime/agent/orchestrator/providers/openai-compat-reasoning-field.mjs +151 -0
  142. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +64 -23
  143. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +79 -33
  144. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2901 -0
  145. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-parity.fixtures.mjs +172 -0
  146. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  147. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +144 -15
  148. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  149. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  150. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +9 -2
  151. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +2 -0
  152. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +109 -26
  153. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +27 -6
  154. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +34 -20
  155. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +23 -4
  156. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +37 -28
  157. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  158. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  159. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +23 -10
  160. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +43 -15
  161. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +10 -0
  162. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-items.mjs +18 -7
  163. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +5 -2
  164. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  165. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +7 -3
  166. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/text-relay.mjs +9 -1
  167. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  168. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  169. package/src/runtime/agent/orchestrator/providers/openai-model-capabilities.mjs +80 -0
  170. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +37 -4
  171. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +12 -1
  172. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  173. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -1
  174. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +76 -33
  175. package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +323 -0
  176. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +44 -16
  177. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  178. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +7 -16
  179. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  180. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/refusals.mjs +60 -0
  181. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/tool-calls.mjs +51 -35
  182. package/src/runtime/agent/orchestrator/providers/openai-ws-response-state.mjs +30 -0
  183. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  184. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  185. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +12 -0
  186. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/terminal-frames.mjs +25 -6
  187. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  188. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +41 -22
  189. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +170 -0
  190. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +75 -70
  191. package/src/runtime/agent/orchestrator/providers/openrouter.mjs +75 -0
  192. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +720 -0
  193. package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -1
  194. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  195. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +123 -219
  196. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  197. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  198. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  199. package/src/runtime/agent/orchestrator/providers/transport-scope.d.mts +6 -0
  200. package/src/runtime/agent/orchestrator/providers/transport-scope.mjs +28 -0
  201. package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +157 -0
  202. package/src/runtime/agent/orchestrator/providers/wire-route.mjs +512 -0
  203. package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +1131 -0
  204. package/src/runtime/agent/orchestrator/providers/xai-tool-search.fixtures.mjs +183 -0
  205. package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +100 -0
  206. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  207. package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +21 -0
  208. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -0
  209. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  210. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  211. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  212. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  213. package/src/runtime/agent/orchestrator/runtime-core/provider-request-snapshot.mjs +7 -3
  214. package/src/runtime/agent/orchestrator/runtime-core/session-title.mjs +21 -1
  215. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +14 -3
  216. package/src/runtime/agent/orchestrator/runtime-core/tool-bridge.mjs +526 -0
  217. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  218. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +10 -7
  219. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +176 -71
  220. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +62 -10
  221. package/src/runtime/agent/orchestrator/runtime-core/tool-exposure.mjs +96 -0
  222. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +5 -1
  223. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -2
  224. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +14 -93
  225. package/src/runtime/agent/orchestrator/session/compact/summary-anchor.mjs +5 -0
  226. package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +120 -0
  227. package/src/runtime/agent/orchestrator/session/context-tool-schema.mjs +17 -2
  228. package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -4
  229. package/src/runtime/agent/orchestrator/session/eager-dispatch/admission.mjs +2 -1
  230. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +6 -1
  231. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +91 -1
  232. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +1 -1
  233. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +56 -0
  234. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +18 -8
  235. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -0
  236. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +21 -10
  237. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +132 -0
  238. package/src/runtime/agent/orchestrator/session/loop/request-boundary.mjs +30 -0
  239. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +67 -20
  240. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +104 -19
  241. package/src/runtime/agent/orchestrator/session/loop/tool-call-resolution.mjs +95 -0
  242. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +30 -3
  243. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +15 -4
  244. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
  245. package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +150 -0
  246. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +7 -2
  247. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  248. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  249. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -1
  250. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +34 -12
  251. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +57 -21
  252. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +57 -25
  253. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +6 -0
  254. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +4 -0
  255. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  256. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  257. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +208 -14
  258. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +16 -4
  259. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +25 -3
  260. package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +82 -0
  261. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +19 -2
  262. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
  263. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  264. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  265. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -1
  266. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +6 -0
  267. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +168 -29
  268. package/src/runtime/agent/orchestrator/session/side-question-store.mjs +96 -0
  269. package/src/runtime/agent/orchestrator/session/side-question.mjs +192 -0
  270. package/src/runtime/agent/orchestrator/session/store/lifecycle-barriers.mjs +17 -3
  271. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +23 -0
  272. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +58 -2
  273. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  274. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +41 -28
  275. package/src/runtime/agent/orchestrator/session/store/session-observers.mjs +4 -2
  276. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +12 -1
  277. package/src/runtime/agent/orchestrator/session/store/write-admission.mjs +3 -1
  278. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  279. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  280. package/src/runtime/agent/orchestrator/session/store-summary-archived-agent.mjs +34 -15
  281. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +6 -0
  282. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +13 -0
  283. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +13 -1
  284. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  285. package/src/runtime/agent/orchestrator/session/store.mjs +19 -3
  286. package/src/runtime/agent/orchestrator/session/tool-batch/execute-call.mjs +1 -0
  287. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +1 -1
  288. package/src/runtime/agent/orchestrator/session/tool-batch/record-outcome.mjs +1 -1
  289. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +7 -1
  290. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -0
  291. package/src/runtime/agent/orchestrator/session/tool-schema-rejection.mjs +77 -0
  292. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  293. package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +8 -2
  294. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  295. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +3 -3
  296. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  297. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  298. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  299. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +14 -10
  300. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +49 -34
  301. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +23 -1
  302. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +1 -1
  303. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1 -1
  304. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +73 -39
  305. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +18 -1
  306. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -4
  307. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +88 -12
  308. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +2 -0
  309. package/src/runtime/computer-bridge/action-schema.mjs +44 -21
  310. package/src/runtime/github/tool.mjs +92 -2
  311. package/src/runtime/memory/index.mjs +1 -0
  312. package/src/runtime/memory/lib/http-router/ingest-routes.mjs +22 -1
  313. package/src/runtime/memory/lib/ingest/transcript-offsets.mjs +2 -0
  314. package/src/runtime/memory/lib/pg/adapter.mjs +98 -1
  315. package/src/runtime/memory/lib/pg/process.mjs +65 -50
  316. package/src/runtime/memory/lib/pg/run-child.mjs +49 -0
  317. package/src/runtime/memory/lib/pg/supervisor.mjs +124 -16
  318. package/src/runtime/memory/lib/runtime-fetcher.mjs +12 -13
  319. package/src/runtime/memory/lib/transcript-ingest.mjs +14 -0
  320. package/src/runtime/office/README.md +2 -2
  321. package/src/runtime/office/authoring/html-document-author.mjs +2 -0
  322. package/src/runtime/office/authoring/pptx-contact-sheet.mjs +2 -0
  323. package/src/runtime/office/authoring/pptx-html-measure.mjs +2 -0
  324. package/src/runtime/office/authoring/pptx-html-runner.mjs +2 -0
  325. package/src/runtime/office/authoring/xlsx-html-runner.mjs +2 -0
  326. package/src/runtime/office/core/office-actions-read.mjs +3 -0
  327. package/src/runtime/office/core/office-render-preview.mjs +2 -0
  328. package/src/runtime/office/core/office-session-dispatch.mjs +7 -0
  329. package/src/runtime/office/core/office-sessionless-actions.mjs +8 -0
  330. package/src/runtime/office/core/office-sessions.mjs +5 -0
  331. package/src/runtime/office/core/office-transactions.mjs +3 -0
  332. package/src/runtime/office/core/office-write-ledger.mjs +98 -0
  333. package/src/runtime/office/design/library/templates/mixdog-executive.mixdog-edit.pptx +0 -0
  334. package/src/runtime/office/index.mjs +30 -0
  335. package/src/runtime/office/pdf/pdf-edit-document.mjs +2 -0
  336. package/src/runtime/office/pdf/pdf-render.mjs +23 -7
  337. package/src/runtime/office/pdf/pdf-security.mjs +2 -0
  338. package/src/runtime/office/portable/portable-opc.mjs +4 -1
  339. package/src/runtime/office/quality/visual-diff.mjs +2 -0
  340. package/src/runtime/office/shared/values.mjs +0 -5
  341. package/src/runtime/shared/background-tasks.mjs +92 -14
  342. package/src/runtime/shared/bounded-file-read.mjs +13 -0
  343. package/src/runtime/shared/bounded-line-diff.mjs +182 -0
  344. package/src/runtime/shared/buffered-appender.mjs +18 -0
  345. package/src/runtime/shared/config.mjs +78 -18
  346. package/src/runtime/shared/err-text.mjs +31 -0
  347. package/src/runtime/shared/error-presentation.mjs +159 -19
  348. package/src/runtime/shared/file-permissions.d.mts +6 -1
  349. package/src/runtime/shared/file-permissions.mjs +54 -6
  350. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  351. package/src/runtime/shared/llm/anthropic-effort.mjs +129 -134
  352. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  353. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  354. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  355. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  356. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  357. package/src/runtime/shared/llm/usage-accounting.mjs +5 -2
  358. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +3 -0
  359. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  360. package/src/runtime/shared/loopback-listener.mjs +9 -0
  361. package/src/runtime/shared/model-display.mjs +13 -0
  362. package/src/runtime/shared/provider-api-key.mjs +2 -0
  363. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  364. package/src/runtime/shared/review-diff.mjs +52 -0
  365. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  366. package/src/runtime/shared/session-sidebar-host.d.mts +4 -0
  367. package/src/runtime/shared/session-sidebar-host.mjs +38 -0
  368. package/src/runtime/shared/slow-span.mjs +114 -0
  369. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  370. package/src/runtime/shared/statusline/statusline-segments.mjs +28 -4
  371. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  372. package/src/runtime/shared/tool-card-model/card-header.mjs +6 -1
  373. package/src/runtime/shared/tool-card-model/generic-detail.mjs +39 -5
  374. package/src/runtime/shared/tool-card-model.mjs +5 -2
  375. package/src/runtime/shared/tool-output-limit.mjs +4 -0
  376. package/src/runtime/shared/tool-primitives.mjs +13 -0
  377. package/src/runtime/shared/tool-result-summary.mjs +8 -1
  378. package/src/runtime/shared/tool-surface.mjs +18 -3
  379. package/src/runtime/shared/tool-work-units.mjs +40 -4
  380. package/src/runtime/shared/transcript-writer/purge.mjs +74 -0
  381. package/src/runtime/shared/transcript-writer.mjs +10 -2
  382. package/src/runtime/shared/turn-changes.mjs +670 -274
  383. package/src/runtime/shared/turn-review-reply.mjs +8 -39
  384. package/src/runtime/shared/turn-snapshot-store.mjs +94 -2
  385. package/src/runtime/shared/turn-snapshot.mjs +986 -690
  386. package/src/runtime/shared/turn-worktree-snapshot.mjs +279 -262
  387. package/src/runtime/shared/user-data-guard.mjs +262 -49
  388. package/src/runtime/shared/worktrees/carry.mjs +531 -0
  389. package/src/runtime/shared/worktrees/contract.mjs +34 -24
  390. package/src/runtime/shared/worktrees/file-recovery.mjs +68 -0
  391. package/src/runtime/shared/worktrees/git-head.mjs +205 -0
  392. package/src/runtime/shared/worktrees/git.mjs +8 -3
  393. package/src/runtime/shared/worktrees/maintenance.mjs +1 -1
  394. package/src/runtime/shared/worktrees/manager.mjs +156 -8
  395. package/src/runtime/shared/worktrees/merge.mjs +31 -21
  396. package/src/runtime/shared/worktrees/paths.d.mts +6 -0
  397. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  398. package/src/runtime/terminal-bridge/client.mjs +44 -11
  399. package/src/runtime/terminal-bridge/tool-defs.mjs +13 -6
  400. package/src/runtime/tidy/apply.mjs +47 -2
  401. package/src/runtime/tidy/run-engines.mjs +11 -5
  402. package/src/session-runtime/agent-tool-routing.mjs +19 -13
  403. package/src/session-runtime/boot/apis.mjs +19 -5
  404. package/src/session-runtime/boot/facade.mjs +1 -0
  405. package/src/session-runtime/boot/infrastructure.mjs +12 -1
  406. package/src/session-runtime/boot/session.mjs +0 -1
  407. package/src/session-runtime/boot/tools.mjs +4 -1
  408. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +12 -5
  409. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +65 -0
  410. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -1
  411. package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +11 -4
  412. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +115 -18
  413. package/src/session-runtime/internal-tool-executor.mjs +6 -2
  414. package/src/session-runtime/lifecycle/cold-heir.mjs +188 -0
  415. package/src/session-runtime/lifecycle/inheritance.mjs +71 -27
  416. package/src/session-runtime/lifecycle/session-catalog.mjs +14 -3
  417. package/src/session-runtime/lifecycle/session-switch/resume.mjs +6 -0
  418. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +29 -6
  419. package/src/session-runtime/lifecycle/teardown.mjs +73 -8
  420. package/src/session-runtime/lifecycle-api.mjs +1 -0
  421. package/src/session-runtime/mcp-glue/connect.mjs +46 -14
  422. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  423. package/src/session-runtime/mcp-glue.mjs +3 -0
  424. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  425. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  426. package/src/session-runtime/model-route/set-route.mjs +85 -17
  427. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  428. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  429. package/src/session-runtime/native-web-search-usage.mjs +51 -0
  430. package/src/session-runtime/native-web-search.mjs +10 -1
  431. package/src/session-runtime/new-session-config.mjs +4 -2
  432. package/src/session-runtime/provider-auth/login.mjs +4 -0
  433. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  434. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  435. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  436. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  437. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  438. package/src/session-runtime/resource-scope-decorate.mjs +10 -3
  439. package/src/session-runtime/runtime-review-api.mjs +21 -2
  440. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +81 -20
  441. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  442. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  443. package/src/session-runtime/services/agent-tool/lead-worker-index/forgotten-leads.mjs +29 -0
  444. package/src/session-runtime/services/agent-tool/lead-worker-index/index-file.mjs +1 -1
  445. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +73 -7
  446. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  447. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  448. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  449. package/src/session-runtime/services/agent-tool/tag-registry.mjs +15 -1
  450. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  451. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  452. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +10 -1
  453. package/src/session-runtime/services/agent-tool/worker-index.mjs +46 -7
  454. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  455. package/src/session-runtime/services/agent-tool.mjs +3 -0
  456. package/src/session-runtime/services/channel-admin.mjs +19 -3
  457. package/src/session-runtime/services/memory-runtime-proxy/calls.mjs +4 -0
  458. package/src/session-runtime/services/memory-runtime-proxy.mjs +1 -0
  459. package/src/session-runtime/services/provider-admin.mjs +81 -15
  460. package/src/session-runtime/session-transcript-purge.mjs +32 -0
  461. package/src/session-runtime/session-turn-api.mjs +14 -5
  462. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -0
  463. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  464. package/src/session-runtime/setup-tool/executor.mjs +36 -6
  465. package/src/session-runtime/setup-tool/extended-actions.mjs +86 -6
  466. package/src/session-runtime/setup-tool/settings-contract.mjs +13 -3
  467. package/src/session-runtime/setup-tool/tool-defs.mjs +13 -4
  468. package/src/session-runtime/skills-api.mjs +3 -5
  469. package/src/session-runtime/tool-surface.mjs +5 -1
  470. package/src/session-runtime/turn/compaction-gate.mjs +25 -0
  471. package/src/session-runtime/turn/session-ops.mjs +7 -5
  472. package/src/session-runtime/turn/side-question-ops.mjs +22 -0
  473. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +5 -1
  474. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +8 -3
  475. package/src/session-runtime/turn/turn-run/turn-settle.mjs +10 -0
  476. package/src/session-runtime/turn/turn-run/turn-timing.mjs +4 -3
  477. package/src/session-runtime/turn/turn-run.mjs +25 -9
  478. package/src/session-runtime/usage-stats-api.mjs +43 -11
  479. package/src/session-runtime/workspace/project-branches.mjs +7 -5
  480. package/src/session-runtime/workspace/transition-steps.mjs +129 -31
  481. package/src/session-runtime/workspace/transitions.mjs +190 -15
  482. package/src/standalone/daemon-agent-control.mjs +9 -1
  483. package/src/standalone/daemon-drain-policy.mjs +8 -0
  484. package/src/standalone/daemon-local-session-bridge.mjs +14 -1
  485. package/src/standalone/daemon-owner.mjs +17 -4
  486. package/src/standalone/daemon-pg-stop.mjs +51 -0
  487. package/src/standalone/daemon-stop-budget.d.mts +2 -0
  488. package/src/standalone/daemon-stop-budget.mjs +18 -0
  489. package/src/standalone/daemon-stored-session-views.mjs +2 -0
  490. package/src/standalone/daemon-telemetry.mjs +74 -0
  491. package/src/standalone/daemon.mjs +64 -8
  492. package/src/standalone/desktop-service-registry.mjs +5 -0
  493. package/src/standalone/local-session-runtime.mjs +70 -19
  494. package/src/standalone/session-client.mjs +42 -0
  495. package/src/standalone/session-protocol.mjs +14 -0
  496. package/src/standalone/session-proxy/projection.mjs +41 -4
  497. package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +15 -1
  498. package/src/standalone/session-runtime-inline-host.mjs +1 -0
  499. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  500. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  501. package/src/standalone/session-service/agent-tree.mjs +12 -0
  502. package/src/standalone/session-service/quota-resume.mjs +8 -0
  503. package/src/standalone/session-service/retention/busy.mjs +15 -1
  504. package/src/standalone/session-service/session-calls/session-action.mjs +18 -3
  505. package/src/standalone/session-service-api.mjs +11 -1
  506. package/src/standalone/session-service.mjs +1 -0
  507. package/src/standalone/session-state-patch.d.mts +2 -1
  508. package/src/standalone/session-state-patch.mjs +41 -1
  509. package/src/tui/App.jsx +25 -0
  510. package/src/tui/app/app-view/floating-panel.jsx +26 -1
  511. package/src/tui/app/app-view.jsx +1 -0
  512. package/src/tui/app/btw-controller.mjs +118 -0
  513. package/src/tui/app/create-app-pickers.mjs +6 -0
  514. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  515. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  516. package/src/tui/app/extension-pickers.mjs +2 -1
  517. package/src/tui/app/model-options.mjs +11 -16
  518. package/src/tui/app/shell-layout/row-budgets.mjs +11 -1
  519. package/src/tui/app/shell-layout/surfaces.mjs +3 -1
  520. package/src/tui/app/slash-commands.mjs +20 -5
  521. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  522. package/src/tui/app/slash-dispatch/session-commands.mjs +19 -6
  523. package/src/tui/app/use-global-key-input/approval-shortcut.mjs +12 -0
  524. package/src/tui/app/use-global-key-input.mjs +5 -12
  525. package/src/tui/app/use-prompt-handlers.mjs +11 -0
  526. package/src/tui/components/BtwPanel.jsx +53 -0
  527. package/src/tui/components/QuestionPanel.jsx +275 -0
  528. package/src/tui/components/StatusLine.jsx +2 -0
  529. package/src/tui/components/question-panel-model.mjs +156 -0
  530. package/src/tui/components/status-line/local-line.mjs +5 -2
  531. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  532. package/src/tui/dist/index.mjs +3221 -1500
  533. package/src/tui/session/context-state.mjs +4 -2
  534. package/src/tui/session/quota-resume.mjs +46 -14
  535. package/src/tui/session/session-api/commands.mjs +43 -24
  536. package/src/tui/session/session-api/controls.mjs +2 -2
  537. package/src/tui/session/session-api/extensions.mjs +22 -0
  538. package/src/tui/session/session-api/integrations.mjs +16 -2
  539. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  540. package/src/tui/session/session-api/lifecycle.mjs +7 -2
  541. package/src/tui/session/session-api/routes.mjs +71 -7
  542. package/src/tui/session/session-api/settings.mjs +11 -14
  543. package/src/tui/session/session-api.mjs +1 -0
  544. package/src/tui/session/session-flow/drain.mjs +7 -1
  545. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  546. package/src/tui/session/tool-approval.mjs +10 -7
  547. package/src/tui/session/transcript-spill.mjs +14 -7
  548. package/src/tui/session/turn-stream.mjs +174 -31
  549. package/src/tui/session/turn.mjs +46 -16
  550. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  551. package/src/ui/statusline-agents.mjs +15 -4
  552. package/src/ui/statusline.mjs +7 -3
  553. package/src/ui/streaming-markdown-heal.mjs +34 -3
  554. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  555. package/src/workflows/default/WORKFLOW.md +3 -1
  556. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  557. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -5,17 +5,28 @@
5
5
  // turn-snapshot-store.mjs. Every file carries its own complete patch, separate
6
6
  // from the bounded display patch the review bar renders, so a historical turn
7
7
  // stays undoable after later turns, cache eviction or a runtime restart.
8
+ import { createHash } from 'node:crypto';
8
9
  import { lstat, open, realpath, stat } from 'node:fs/promises';
9
- import { isAbsolute, relative, resolve } from 'node:path';
10
- import { applyPatch, parsePatch, reversePatch, structuredPatch } from 'diff';
10
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
11
+ import { deflateSync } from 'node:zlib';
11
12
  import { cleanString as clean } from './clean.mjs';
12
13
  import { runGitOffThread } from './git-runner.mjs';
14
+ import { createLineDiffBudget, lineDiffHunks } from './bounded-line-diff.mjs';
15
+ import {
16
+ createReviewPatchBudget,
17
+ filePatchReply,
18
+ PATCH_TOO_LARGE,
19
+ patchOmittedFile,
20
+ REVIEW_FILE_PATCH_MAX_BYTES,
21
+ REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
22
+ REVIEW_PATCH_MAX_BYTES,
23
+ reviewDiffTooLarge,
24
+ } from './review-diff.mjs';
13
25
 
14
26
  /** Largest single-file patch a record keeps replayable. */
15
27
  export const TURN_CHANGE_FILE_PATCH_MAX_BYTES = 8 * 1024 * 1024;
16
28
  const APPLY_TIMEOUT_MS = 60_000;
17
29
  const APPLY_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
18
- const TRACKED_DIFF_TIMEOUT_MS = 2_000;
19
30
  const STATUSES = new Set(['A', 'M', 'D', 'R']);
20
31
  // Characters a patch header cannot carry unquoted; such a path is recorded but
21
32
  // never replayed.
@@ -156,13 +167,15 @@ export function splitPatchSections(buffer) {
156
167
  });
157
168
  }
158
169
 
159
- // ── Exact tracker (non-Git) patches ─────────────────────────────────────────
170
+ // ── Exact tracker patches (the session's own tool bytes) ────────────────────
160
171
 
161
172
  const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
162
- function decodeText(content) {
173
+ /** Text of one change side (bytes or a string): '' when absent, null for
174
+ * binary content (not UTF-8, or holding a NUL byte). */
175
+ export function decodeText(content) {
163
176
  if (content == null) return '';
164
177
  try {
165
- const text = utf8Decoder.decode(content);
178
+ const text = typeof content === 'string' ? content : utf8Decoder.decode(content);
166
179
  // A NUL byte marks binary content: it gets no text diff.
167
180
  return text.includes('\0') ? null : text;
168
181
  } catch {
@@ -170,13 +183,79 @@ function decodeText(content) {
170
183
  }
171
184
  }
172
185
 
186
+ /** True for bytes that get a binary patch instead of a text diff (not UTF-8,
187
+ * or holding a NUL byte). */
188
+ export function isBinaryChangeContent(content) {
189
+ return content != null && decodeText(content) === null;
190
+ }
191
+
173
192
  const headerTab = (path) => (path.includes(' ') ? '\t' : '');
174
193
 
194
+ /** The object format (`sha1`/`sha256`) `git apply` hashes blobs with in `root`:
195
+ * the repository's own, or sha1 outside any repository. */
196
+ export async function repositoryObjectFormat(root) {
197
+ const result = await runGitOffThread(['rev-parse', '--show-object-format'], {
198
+ cwd: root,
199
+ timeoutMs: APPLY_TIMEOUT_MS,
200
+ maxBytes: APPLY_OUTPUT_MAX_BYTES,
201
+ }).catch(() => null);
202
+ return result?.code === 0 && clean(result.stdout) === 'sha256' ? 'sha256' : 'sha1';
203
+ }
204
+
205
+ const BASE85 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~';
206
+
207
+ /** One line of a git binary hunk: a length letter, then 4-byte groups as base85. */
208
+ function base85Line(chunk) {
209
+ let line = String.fromCharCode(chunk.length <= 26 ? 64 + chunk.length : 70 + chunk.length);
210
+ for (let offset = 0; offset < chunk.length; offset += 4) {
211
+ let value = 0;
212
+ for (let index = 0; index < 4; index += 1) value = value * 256 + (chunk[offset + index] ?? 0);
213
+ const group = new Array(5);
214
+ for (let index = 4; index >= 0; index -= 1) {
215
+ group[index] = BASE85[value % 85];
216
+ value = Math.floor(value / 85);
217
+ }
218
+ line += group.join('');
219
+ }
220
+ return line;
221
+ }
222
+
223
+ function binaryLiteral(bytes) {
224
+ const deflated = deflateSync(bytes);
225
+ let text = `literal ${bytes.length}\n`;
226
+ for (let offset = 0; offset < deflated.length; offset += 52) {
227
+ text += `${base85Line(deflated.subarray(offset, offset + 52))}\n`;
228
+ }
229
+ return `${text}\n`;
230
+ }
231
+
232
+ /** The `index` line and `GIT binary patch` body of a byte change. Full blob
233
+ * ids make `git apply` verify the file still holds the recorded bytes on
234
+ * either side before it writes, so a binary file changed since is refused. */
235
+ function binaryPatchBody(before, after, status, objectFormat) {
236
+ const empty = Buffer.alloc(0);
237
+ const id = (bytes) => createHash(objectFormat).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
238
+ const zero = '0'.repeat(objectFormat === 'sha256' ? 64 : 40);
239
+ const oldId = before === null ? zero : id(before);
240
+ const newId = after === null ? zero : id(after);
241
+ return `index ${oldId}..${newId}${status === 'M' ? ' 100644' : ''}\nGIT binary patch\n${binaryLiteral(after ?? empty)}${binaryLiteral(before ?? empty)}`;
242
+ }
243
+
175
244
  /**
176
245
  * One git-format patch from exact before/after bytes (null = absent). Paths
177
- * are root-relative. Returns the record file without its state.
246
+ * are root-relative. Text diffs as hunks within `budget` (see
247
+ * bounded-line-diff.mjs); binary content (isBinaryChangeContent) as a
248
+ * full-index binary patch hashed in `objectFormat`. Returns the record file
249
+ * without its state.
178
250
  */
179
- export function trackedChangeFile({ oldPath, newPath, before, after }) {
251
+ export function trackedChangeFile({
252
+ oldPath,
253
+ newPath,
254
+ before,
255
+ after,
256
+ objectFormat = 'sha1',
257
+ budget = createLineDiffBudget(),
258
+ }) {
180
259
  let status = 'M';
181
260
  if (before === null) status = 'A';
182
261
  else if (after === null) status = 'D';
@@ -189,22 +268,16 @@ export function trackedChangeFile({ oldPath, newPath, before, after }) {
189
268
  const oldText = decodeText(before);
190
269
  const newText = decodeText(after);
191
270
  if (oldText === null || newText === null) {
192
- return {
193
- ...base,
194
- binary: true,
195
- revertable: false,
196
- reason: 'binary content was not recorded as a replayable patch',
197
- };
198
- }
199
- let hunks = [];
200
- if (oldText !== newText) {
201
- const result = structuredPatch(`a/${oldPath}`, `b/${newPath}`, oldText, newText, '', '', {
202
- context: 3,
203
- timeout: TRACKED_DIFF_TIMEOUT_MS,
204
- });
205
- if (!result) return { ...base, revertable: false, reason: 'the diff took too long to compute' };
206
- hunks = result.hunks;
271
+ let patch = `diff --git a/${status === 'A' ? newPath : oldPath} b/${status === 'D' ? oldPath : newPath}\n`;
272
+ if (status === 'A') patch += 'new file mode 100644\n';
273
+ if (status === 'D') patch += 'deleted file mode 100644\n';
274
+ if (status === 'R') patch += `rename from ${oldPath}\nrename to ${newPath}\n`;
275
+ // A pure rename carries the full-index body too: `git apply` then moves
276
+ // the file only while it still holds the recorded bytes.
277
+ patch += binaryPatchBody(before, after, status, objectFormat);
278
+ return { ...base, binary: true, revertable: true, patch: Buffer.from(patch, 'utf8') };
207
279
  }
280
+ const { hunks } = lineDiffHunks(oldText, newText, budget, [before, after]);
208
281
  const oldName = status === 'A' ? newPath : oldPath;
209
282
  const newName = status === 'D' ? oldPath : newPath;
210
283
  let patch = `diff --git a/${oldName} b/${newName}\n`;
@@ -344,25 +417,33 @@ export function turnChangesReply({ status, checkpointId, record = null, patch =
344
417
  return reply;
345
418
  }
346
419
 
347
- // ── Session review: net recorded changes ────────────────────────────────────
420
+ // ── Session review: first-seen baselines against the disk ───────────────────
348
421
 
349
422
  export const NET_TEXT_MAX_BYTES = 1024 * 1024;
350
423
  /** Budget of the merged session patch, in UTF-8 bytes. */
351
- export const MERGED_PATCH_MAX_BYTES = 2 * 1024 * 1024;
424
+ export const MERGED_PATCH_MAX_BYTES = REVIEW_PATCH_MAX_BYTES;
352
425
  const VIEW_PATCH_MAX_BYTES = 256 * 1024;
353
426
 
354
- /** A git-format patch of exact before/after text under the names a viewer
355
- * shows (absolute for a file outside the worktree). `before`/`after` are
356
- * Buffers or null (absent). */
357
- export function namedChangePatch({ before, after, oldName, newName, maxBytes = VIEW_PATCH_MAX_BYTES }) {
358
- const renamed = oldName !== newName;
427
+ /** Counts and a placeholder-named patch of exact before/after bytes. */
428
+ function measureChange(before, after, renamed, budget) {
359
429
  const measured = trackedChangeFile({
360
430
  oldPath: renamed ? 'mx-old-file' : 'mx-file',
361
431
  newPath: renamed ? 'mx-new-file' : 'mx-file',
362
432
  before,
363
433
  after,
434
+ budget,
364
435
  });
365
- const text = measured.patch ? measured.patch.toString('utf8') : '';
436
+ return {
437
+ status: measured.status,
438
+ additions: measured.additions,
439
+ deletions: measured.deletions,
440
+ ...(measured.binary ? { binary: true } : {}),
441
+ text: measured.patch ? measured.patch.toString('utf8') : '',
442
+ };
443
+ }
444
+
445
+ /** A measured patch with its placeholders replaced by the names a viewer shows. */
446
+ function nameMeasuredPatch(text, oldName, newName) {
366
447
  const split = text.search(/\n@@ /);
367
448
  const head = (split >= 0 ? text.slice(0, split) : text)
368
449
  .split('mx-old-file')
@@ -371,48 +452,76 @@ export function namedChangePatch({ before, after, oldName, newName, maxBytes = V
371
452
  .join(newName)
372
453
  .split('mx-file')
373
454
  .join(newName);
374
- const patch = head + (split >= 0 ? text.slice(split) : '');
375
- return {
376
- status: measured.status,
377
- additions: measured.additions,
378
- deletions: measured.deletions,
379
- ...(measured.binary ? { binary: true } : {}),
380
- patch: Buffer.byteLength(patch, 'utf8') > maxBytes ? '' : patch,
381
- };
455
+ return head + (split >= 0 ? text.slice(split) : '');
456
+ }
457
+
458
+ /** A git-format patch of exact before/after text under the names a viewer
459
+ * shows (absolute for a file outside the worktree). `before`/`after` are
460
+ * Buffers or null (absent); `budget` the operation's line-diff budget. */
461
+ export function namedChangePatch({
462
+ before,
463
+ after,
464
+ oldName,
465
+ newName,
466
+ maxBytes = VIEW_PATCH_MAX_BYTES,
467
+ budget = createLineDiffBudget(),
468
+ }) {
469
+ const { text, ...measured } = measureChange(before, after, oldName !== newName, budget);
470
+ const patch = nameMeasuredPatch(text, oldName, newName);
471
+ return { ...measured, patch: Buffer.byteLength(patch, 'utf8') > maxBytes ? '' : patch };
382
472
  }
383
473
 
384
474
  const slashPath = (value) => clean(value).replace(/\\/g, '/');
385
475
  const isOutsidePath = (path) => isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path);
386
- const absoluteKey = (root, path) => changePathKey(isOutsidePath(path) ? resolve(path) : resolve(root, path));
476
+ const absolutePath = (root, path) => (isOutsidePath(path) ? resolve(path) : resolve(root, path));
387
477
  const patchOf = (file) =>
388
478
  changePatchBuffer(file)?.toString('utf8') || (typeof file?.viewPatch === 'string' ? file.viewPatch : '');
389
479
 
480
+ /** A record file's patch as a review shows it: a binary body becomes the
481
+ * `Binary files … differ` line a diff viewer renders. */
482
+ export function changeDisplayPatch(file) {
483
+ const buffer =
484
+ changePatchBuffer(file) || (typeof file?.viewPatch === 'string' ? Buffer.from(file.viewPatch, 'utf8') : null);
485
+ return splitPatchSections(buffer)
486
+ .map(({ oldPath, newPath, patch }) => {
487
+ const text = patch.toString('utf8');
488
+ const body = text.indexOf('\nGIT binary patch\n');
489
+ if (body < 0) return text;
490
+ const head = text.slice(0, body + 1).replace(/^index .*\n/m, '');
491
+ return `${head}Binary files ${oldPath === null ? '/dev/null' : `a/${oldPath}`} and ${newPath === null ? '/dev/null' : `b/${newPath}`} differ\n`;
492
+ })
493
+ .join('');
494
+ }
495
+
390
496
  const insideDirectory = (parent, child) => {
391
497
  const rel = relative(parent, child);
392
498
  return rel === '' || (!isAbsolute(rel) && rel.split(/[\\/]+/)[0] !== '..');
393
499
  };
394
500
 
395
- /** At most NET_TEXT_MAX_BYTES of a regular file, read through one handle:
396
- * undefined when it is larger (checked by size first, never read whole). */
397
- async function readBounded(file) {
501
+ /** At most `maxBytes` of a regular file, read through one handle: undefined
502
+ * when it is larger (checked by size first, never read whole). */
503
+ async function readBounded(file, maxBytes = NET_TEXT_MAX_BYTES) {
398
504
  const handle = await open(file, 'r');
399
505
  try {
400
506
  const info = await handle.stat();
401
- if (!info.isFile() || info.size > NET_TEXT_MAX_BYTES) return undefined;
402
- const buffer = Buffer.alloc(NET_TEXT_MAX_BYTES + 1);
507
+ if (!info.isFile() || info.size > maxBytes) return undefined;
508
+ // Sized by the file, one byte over: a file that grew since its stat is
509
+ // not read part-way.
510
+ const buffer = Buffer.alloc(info.size + 1);
403
511
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
404
- return bytesRead > NET_TEXT_MAX_BYTES ? undefined : buffer.subarray(0, bytesRead);
512
+ return bytesRead > info.size ? undefined : buffer.subarray(0, bytesRead);
405
513
  } finally {
406
514
  await handle.close();
407
515
  }
408
516
  }
409
517
 
410
- /** The file's current text: a string, null when absent, undefined when it
411
- * cannot be netted (unreadable, binary, large, or reached through a link).
412
- * An in-root file is followed only while it stays inside the recorded root;
413
- * an outside file is read only at its exact recorded path, never through a
414
- * link. */
415
- export async function currentText(root, path) {
518
+ const missingFile = (error) => error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
519
+
520
+ /** The recorded file's absolute path when it may be read: null when absent,
521
+ * undefined when it is reached through a link. An in-root file is followed
522
+ * only while it stays inside the recorded root; an outside file is read only
523
+ * at its exact recorded path, never through a link. */
524
+ async function safeCurrentFile(root, path) {
416
525
  const outside = isOutsidePath(path);
417
526
  const file = outside ? resolve(path) : resolve(root, path);
418
527
  try {
@@ -426,10 +535,22 @@ export async function currentText(root, path) {
426
535
  const [realRoot, realFile] = await Promise.all([realpath(root), realpath(file)]);
427
536
  if (!insideDirectory(realRoot, realFile)) return undefined;
428
537
  }
538
+ return file;
539
+ } catch (error) {
540
+ return missingFile(error) ? null : undefined;
541
+ }
542
+ }
543
+
544
+ /** The file's current text: a string, null when absent, undefined when it
545
+ * cannot be netted (unreadable, binary, large, or reached through a link). */
546
+ export async function currentText(root, path) {
547
+ const file = await safeCurrentFile(root, path);
548
+ if (!file) return file;
549
+ try {
429
550
  const bytes = await readBounded(file);
430
551
  return bytes === undefined ? undefined : (decodeText(bytes) ?? undefined);
431
552
  } catch (error) {
432
- return error?.code === 'ENOENT' || error?.code === 'ENOTDIR' ? null : undefined;
553
+ return missingFile(error) ? null : undefined;
433
554
  }
434
555
  }
435
556
 
@@ -448,268 +569,536 @@ function isUnmeasuredFile(file) {
448
569
 
449
570
  /** A legacy record kept no counts or diff for a new file it could not replay
450
571
  * (outside the root): measure the file as it is on disk now. Returns the
451
- * file with its counts and display patch, or the file unchanged. */
452
- export async function measureLegacyChangeFile(file, root) {
572
+ * file with its counts and display patch, or the file unchanged. `budget`:
573
+ * the line-diff budget of the read it is part of. */
574
+ export async function measureLegacyChangeFile(file, root, budget = createLineDiffBudget()) {
453
575
  if (!isUnmeasuredFile(file)) return file;
454
576
  const text = await currentText(root, file.path);
455
577
  if (typeof text !== 'string' || !text) return file;
456
578
  const name = slashPath(file.path);
457
- const named = namedChangePatch({ before: null, after: Buffer.from(text, 'utf8'), oldName: name, newName: name });
579
+ const named = namedChangePatch({
580
+ before: null,
581
+ after: Buffer.from(text, 'utf8'),
582
+ oldName: name,
583
+ newName: name,
584
+ budget,
585
+ });
458
586
  if (!named.patch) return file;
459
587
  return { ...file, additions: named.additions, deletions: named.deletions, viewPatch: named.patch };
460
588
  }
461
589
 
462
- /** The text a chain's file had before its first recorded turn, rebuilt by
463
- * reverse-applying the recorded per-turn patches, newest first, onto the
464
- * file as it is now (`current`: string, or null when absent). null = the file
465
- * did not exist; undefined = a patch is missing or no longer matches, so the
466
- * chain cannot be netted exactly. Nothing about the before-state is stored. */
467
- function originalText(entries, current) {
468
- let text = current;
469
- for (let index = entries.length - 1; index >= 0; index -= 1) {
470
- const { file } = entries[index];
471
- const patch = patchOf(file);
472
- if (!patch || file.binary === true) return undefined;
473
- let sections;
474
- try {
475
- sections = parsePatch(patch);
476
- } catch {
477
- return undefined;
478
- }
479
- if (sections.length !== 1) return undefined;
480
- const [section] = sections;
481
- if (file.status === 'D') {
482
- if (text !== null) return undefined;
483
- text = '';
484
- } else if (text === null) {
485
- return undefined;
486
- }
487
- if (section.hunks.length) {
488
- const result = applyPatch(text, reversePatch(section));
489
- if (result === false) return undefined;
490
- text = result;
590
+ // Largest current file hashed against a baseline whose bytes were not kept.
591
+ const DIGEST_MAX_BYTES = 64 * 1024 * 1024;
592
+ // Largest measured patch a review keeps cached between polls.
593
+ const CACHED_PATCH_MAX_BYTES = 256 * 1024;
594
+ const READ_CONCURRENCY = 16;
595
+ const CURRENT_READ_MAX_BYTES = REVIEW_FILE_PATCH_SHOW_MAX_BYTES;
596
+ /** A file whose current content is past CURRENT_READ_MAX_BYTES (or cannot be
597
+ * read whole): listed without a patch, explicitly too large. */
598
+ const tooLargeChange = (status) => ({ status, additions: 0, deletions: 0, text: '', patchOmitted: true });
599
+ const UNREADABLE_REASON = 'the file cannot be read safely now';
600
+ const SEVERAL_TURNS_REASON = 'edited in several turns before its session baseline was recorded';
601
+ /** Why a listed file has no exact diff against the session's first state. */
602
+ const BASELINE_REASONS = {
603
+ 'omitted-binary': "the session's original binary content was not captured",
604
+ 'omitted-oversized': "the session's original content was too large to capture",
605
+ evicted: "the session's original content was evicted to bound storage",
606
+ missing: "the session's original state was not recorded",
607
+ };
608
+
609
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
610
+
611
+ async function mapLimit(items, limit, task) {
612
+ const results = new Array(items.length);
613
+ let next = 0;
614
+ const worker = async () => {
615
+ while (next < items.length) {
616
+ const index = next++;
617
+ results[index] = await task(items[index]);
491
618
  }
492
- if (file.status === 'A') {
493
- if (text !== '') return undefined;
494
- text = null;
619
+ };
620
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
621
+ return results;
622
+ }
623
+
624
+ /** An absolute file under the link rules of currentText: relative to its
625
+ * recorded root when inside it, else its exact path. */
626
+ const fileRef = (root, abs) =>
627
+ root && insideDirectory(root, abs) ? { root, path: relative(root, abs) } : { root: dirname(abs), path: abs };
628
+
629
+ /** `{ state: 'absent' | 'unsafe' }` or `{ state: 'present', file, size, sig }`. */
630
+ async function currentFile({ root, path }) {
631
+ const file = await safeCurrentFile(root, path);
632
+ if (file === null) return { state: 'absent' };
633
+ if (file === undefined) return { state: 'unsafe' };
634
+ try {
635
+ const info = await stat(file);
636
+ if (!info.isFile()) return { state: 'unsafe' };
637
+ return { state: 'present', file, size: info.size, sig: `${info.size}:${info.mtimeMs}:${info.ctimeMs}` };
638
+ } catch (error) {
639
+ return { state: missingFile(error) ? 'absent' : 'unsafe' };
640
+ }
641
+ }
642
+
643
+ const currentSig = (current) => (current?.state === 'present' ? current.sig : current?.state || '');
644
+
645
+ /** Bytes of a present file up to CURRENT_READ_MAX_BYTES — what one file's
646
+ * own patch read may show, so a file past the reply's per-file cap still has
647
+ * its counts and its patch on request; undefined past it or when it cannot
648
+ * be read. */
649
+ async function currentBytes(current) {
650
+ if (current.size > CURRENT_READ_MAX_BYTES) return undefined;
651
+ try {
652
+ return await readBounded(current.file, CURRENT_READ_MAX_BYTES);
653
+ } catch {
654
+ return undefined;
655
+ }
656
+ }
657
+
658
+ async function fileDigest(file) {
659
+ let handle;
660
+ try {
661
+ handle = await open(file, 'r');
662
+ const info = await handle.stat();
663
+ if (!info.isFile() || info.size > DIGEST_MAX_BYTES) return undefined;
664
+ const hash = createHash('sha256');
665
+ const buffer = Buffer.alloc(1024 * 1024);
666
+ for (let position = 0; ; ) {
667
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
668
+ if (!bytesRead) break;
669
+ hash.update(buffer.subarray(0, bytesRead));
670
+ position += bytesRead;
495
671
  }
672
+ return hash.digest('hex');
673
+ } catch {
674
+ return undefined;
675
+ } finally {
676
+ await handle?.close().catch(() => {});
496
677
  }
497
- return text;
498
678
  }
499
679
 
500
- /** A recorded patch re-headed with the names the merged review shows, so a
501
- * disambiguated file keeps one identity in its list row and its diff. */
502
- function retargetPatch(patch, { status, oldName, newName }) {
503
- const split = patch.search(/\n@@ /);
504
- const head = split >= 0 ? patch.slice(0, split) : patch;
505
- const tail = split >= 0 ? patch.slice(split) : '';
506
- const from = status === 'R' ? oldName : newName;
507
- const tab = (name) => (name.includes(' ') ? '\t' : '');
508
- const lines = head.split('\n').map((line) => {
509
- if (line.startsWith('diff --git ')) return `diff --git a/${from} b/${newName}`;
510
- if (line.startsWith('--- ') && !line.startsWith('--- /dev/null')) return `--- a/${from}${tab(from)}`;
511
- if (line.startsWith('+++ ') && !line.startsWith('+++ /dev/null')) return `+++ b/${newName}${tab(newName)}`;
512
- if (line.startsWith('rename from ')) return `rename from ${from}`;
513
- if (line.startsWith('rename to ')) return `rename to ${newName}`;
514
- return line;
680
+ /** Whether a present file still holds the baseline's bytes: true, false, or
681
+ * undefined when that cannot be told. */
682
+ async function sameAsBaseline(entry, current, after) {
683
+ if (entry.size > 0 && entry.size !== current.size) return false;
684
+ if (!entry.sha256) return undefined;
685
+ const hash = after ? sha256(after) : await fileDigest(current.file);
686
+ return hash === undefined ? undefined : hash === entry.sha256;
687
+ }
688
+
689
+ /** A row the review cannot measure exactly: it never vouches for the set. */
690
+ const partialChange = (status, reason, extra = {}) => ({
691
+ status,
692
+ additions: 0,
693
+ deletions: 0,
694
+ text: '',
695
+ reason,
696
+ partial: true,
697
+ ...extra,
698
+ });
699
+
700
+ const baselineGap = (entry, status) =>
701
+ partialChange(status, BASELINE_REASONS[entry.status] || BASELINE_REASONS.missing, {
702
+ baseline: entry.status,
703
+ ...(entry.status === 'omitted-binary' ? { binary: true } : {}),
515
704
  });
516
- return lines.join('\n') + tail;
517
- }
518
-
519
- /** One path's turns, oldest first, as a single net change; null when the
520
- * session left the file as it found it. The original is rebuilt from the
521
- * recorded patches (see originalText) and compared with the disk, so undo,
522
- * create→delete and renames fall out; when that fails the turns are merged
523
- * per turn. `show` names a file for display. */
524
- async function netChain(chain, show) {
525
- const first = chain.entries[0];
526
- const last = chain.entries.at(-1);
527
- const path = show(last.root, last.file.path);
528
- const firstRel = first.file.oldPath || first.file.path;
529
- const oldName = absoluteKey(first.root, firstRel) === chain.key ? path : show(first.root, firstRel);
530
- const identity = {
531
- id: slashPath(isOutsidePath(last.file.path) ? resolve(last.file.path) : resolve(last.root, last.file.path)),
532
- outside: isOutsidePath(last.file.path),
533
- };
534
- const after = await currentText(last.root, last.file.path);
535
- if (after !== undefined) {
536
- const before = originalText(chain.entries, after);
537
- if (before !== undefined) {
538
- if (before === after && oldName === path) return null;
539
- const net = namedChangePatch({
540
- before: before === null ? null : Buffer.from(before, 'utf8'),
541
- after: after === null ? null : Buffer.from(after, 'utf8'),
542
- oldName,
543
- newName: path,
544
- maxBytes: MERGED_PATCH_MAX_BYTES,
545
- });
546
- const omitted = !net.patch && net.additions + net.deletions > 0;
547
- return {
548
- path,
549
- ...identity,
550
- ...(oldName !== path ? { oldPath: oldName } : {}),
551
- ...net,
552
- ...(omitted ? { patchOmitted: true } : {}),
553
- };
705
+
706
+ /** A file matching the hash of a first state whose bytes were not kept: not
707
+ * listed, but it does not let the review vouch for the set either. */
708
+ const UNVOUCHED_UNCHANGED = Object.freeze({ hidden: true, partial: true });
709
+
710
+ /** One file (or rename) measured from its first-seen state `entry` to the
711
+ * disk now (`current`); null when the session left it as it found it,
712
+ * UNVOUCHED_UNCHANGED when only a hash says so. */
713
+ async function measureBaseline(entry, current, renamed, readBlob, budget) {
714
+ const changed = renamed ? 'R' : 'M';
715
+ if (current.state === 'unsafe') {
716
+ return partialChange(entry.state === 'absent' ? 'A' : changed, UNREADABLE_REASON);
717
+ }
718
+ const present = current.state === 'present';
719
+ const after = present ? await currentBytes(current) : null;
720
+ if (entry.state === 'absent') {
721
+ if (!present) return null;
722
+ if (after === undefined) return tooLargeChange('A');
723
+ return measureChange(null, after, false, budget);
724
+ }
725
+ if (present) {
726
+ const same = await sameAsBaseline(entry, current, after);
727
+ const exact = entry.status === 'captured';
728
+ if (same === true && !renamed) return exact ? null : UNVOUCHED_UNCHANGED;
729
+ if (same === true) {
730
+ const moved = measureChange(Buffer.alloc(0), Buffer.alloc(0), true, budget);
731
+ return exact
732
+ ? moved
733
+ : { ...moved, partial: true, baseline: entry.status, reason: BASELINE_REASONS[entry.status] };
554
734
  }
735
+ if (same === undefined || !exact) return baselineGap(entry, changed);
555
736
  }
556
- if (first.file.status === 'A' && last.file.status === 'D') return null;
557
- const files = await Promise.all(chain.entries.map((entry) => measureLegacyChangeFile(entry.file, entry.root)));
558
- const patches = files
559
- .map((file) => {
560
- const patch = patchOf(file);
561
- return patch ? retargetPatch(patch, { status: file.status, oldName, newName: path }) : '';
562
- })
563
- .filter(Boolean);
564
- const additions = files.reduce((sum, file) => sum + (Number(file.additions) || 0), 0);
565
- const deletions = files.reduce((sum, file) => sum + (Number(file.deletions) || 0), 0);
566
- const binary = files.some((file) => file.binary === true);
567
- const patch = patches.join('');
568
- return {
569
- path,
570
- ...identity,
571
- ...(oldName !== path ? { oldPath: oldName } : {}),
572
- status: first.file.status === 'A' ? 'A' : last.file.status,
573
- additions,
574
- deletions,
575
- ...(binary ? { binary: true } : {}),
576
- patch,
577
- // A legacy record that measured nothing: the transcript may know more.
578
- ...(!patch && additions + deletions === 0 && !binary ? { unmeasured: true, reason: clean(last.file.reason) } : {}),
579
- ...(!patch && last.file.patchOmitted === true ? { patchOmitted: true } : {}),
580
- };
737
+ const before = entry.status === 'captured' ? await readBlob(entry) : undefined;
738
+ if (!before)
739
+ return baselineGap(entry.status === 'captured' ? { ...entry, status: 'evicted' } : entry, present ? changed : 'D');
740
+ if (after === undefined) return tooLargeChange(changed);
741
+ return measureChange(before, after, renamed, budget);
742
+ }
743
+
744
+ /** A list measurement as a reply keeps it: text past the per-file inline cap
745
+ * is dropped at once, its counts kept and the file marked too large. */
746
+ function inlineChange(change) {
747
+ if (Buffer.byteLength(change?.text || '', 'utf8') <= REVIEW_FILE_PATCH_MAX_BYTES) return change;
748
+ return { ...change, text: '', patchOmitted: true };
749
+ }
750
+
751
+ /** Every baseline not `covered` by another source, measured against the disk
752
+ * within the review's line-diff `budget`. A path holding a renamed file pairs
753
+ * with the path it came from when that one is gone. Measurements are reused
754
+ * while the files' stats are unchanged. With `target` (an entry predicate)
755
+ * only the units it matches are measured, with their whole text; every other
756
+ * unit is returned `nameOnly`, for naming. */
757
+ async function baselineChanges(baselines, covered, unvouched, budget, { target = null } = {}) {
758
+ const entries = new Map();
759
+ for (const entry of baselines.files) if (!covered(entry.path)) entries.set(entry.key, entry);
760
+ const list = [...entries.values()];
761
+ const states = await mapLimit(list, READ_CONCURRENCY, (entry) => currentFile(fileRef(entry.root, entry.path)));
762
+ const current = new Map(list.map((entry, index) => [entry.key, states[index]]));
763
+ const paired = new Set();
764
+ const units = [];
765
+ for (const [destinationKey, originKey] of baselines.renames) {
766
+ const destination = entries.get(destinationKey);
767
+ const origin = entries.get(originKey);
768
+ if (!destination || !origin || paired.has(destinationKey) || paired.has(originKey)) continue;
769
+ if (destination.state !== 'absent' || origin.state === 'absent') continue;
770
+ if (current.get(destinationKey).state === 'absent' || current.get(originKey).state !== 'absent') continue;
771
+ paired.add(destinationKey);
772
+ paired.add(originKey);
773
+ units.push({ key: `${originKey}\0${destinationKey}`, before: origin, after: destination });
774
+ }
775
+ for (const entry of list) if (!paired.has(entry.key)) units.push({ key: entry.key, before: entry, after: entry });
776
+ const measured = await mapLimit(units, READ_CONCURRENCY, async (unit) => {
777
+ const renamed = unit.before !== unit.after;
778
+ const named = {
779
+ key: unit.after.key,
780
+ abs: unit.after.path,
781
+ root: unit.after.root,
782
+ ...(renamed ? { oldAbs: unit.before.path, oldRoot: unit.before.root } : {}),
783
+ };
784
+ const now = current.get(unit.after.key);
785
+ const sig = [
786
+ unit.before.state,
787
+ unit.before.status,
788
+ unit.before.sha256 || '',
789
+ unit.before.blob || '',
790
+ unit.before.at,
791
+ unit.after.at,
792
+ currentSig(now),
793
+ currentSig(current.get(unit.before.key)),
794
+ ].join('|');
795
+ const cached = baselines.cache?.get(unit.key);
796
+ if (target && !target(unit.after) && !target(unit.before)) {
797
+ // Not measured: it names a file unless the cache knows it is unchanged.
798
+ const unchanged = cached?.sig === sig && (!cached.change || cached.change.hidden);
799
+ return unchanged ? null : { ...named, nameOnly: true };
800
+ }
801
+ // A file's own patch read measures again what was cached without its text.
802
+ let change = cached?.sig === sig && !(target && cached.stripped) ? cached.change : undefined;
803
+ if (change === undefined) {
804
+ const whole = await measureBaseline(unit.before, now, renamed, baselines.readBlob, budget);
805
+ const inline = inlineChange(whole);
806
+ // Once the budget is spent a diff may have been cut short to the
807
+ // replacement hunk: such a measurement is not kept, a later review
808
+ // measures it again.
809
+ if (budget.remainingMs > 0 && Buffer.byteLength(inline?.text || '', 'utf8') <= CACHED_PATCH_MAX_BYTES) {
810
+ baselines.cache?.set(unit.key, { sig, change: inline, ...(inline !== whole ? { stripped: true } : {}) });
811
+ }
812
+ change = target ? whole : inline;
813
+ }
814
+ if (change?.hidden) unvouched.add(unit.after.key);
815
+ if (!change || change.hidden) return null;
816
+ return { ...named, change };
817
+ });
818
+ return measured.filter(Boolean);
581
819
  }
582
820
 
583
- /** Group the records' files into per-path chains (renames continue a chain).
584
- * A chain is keyed by root + path, so two roots' `x.txt` stay two files. */
585
- function recordChains(records, { inRootKinds }) {
586
- const chains = new Map();
821
+ /**
822
+ * Files of the retained turn records that no baseline lists — a session (or
823
+ * turns) recorded before baselines existed. Never exact: a file edited in one
824
+ * turn keeps that turn's diff, one edited in several is listed unmeasured.
825
+ * Also returns the baseline keys such older records touched: those baselines
826
+ * were first seen after an unrecorded edit.
827
+ */
828
+ async function legacyChanges(records, { kinds, listed, covered, since, budget }) {
829
+ const groups = new Map();
830
+ const late = new Set();
587
831
  for (const record of records) {
588
832
  const root = clean(record?.root);
589
833
  const entries = [
590
- ...(inRootKinds.has(record?.kind) ? record.files || [] : []),
834
+ ...(kinds.has(record?.kind) ? record.files || [] : []),
591
835
  ...(Array.isArray(record?.outside) ? record.outside : []),
592
836
  ];
593
837
  for (const file of entries) {
594
838
  if (!file?.path || file.state === 'reverted') continue;
595
- const key = absoluteKey(root, file.path);
596
- const from = file.oldPath ? absoluteKey(root, file.oldPath) : key;
597
- const chain = chains.get(from) || chains.get(key) || { entries: [] };
598
- chains.delete(chain.key);
599
- chain.entries.push({ file, root });
600
- chain.key = key;
601
- chains.set(key, chain);
839
+ const abs = absolutePath(root, file.path);
840
+ const key = changePathKey(abs);
841
+ const oldAbs = file.oldPath ? absolutePath(root, file.oldPath) : '';
842
+ if (covered(abs)) continue;
843
+ if (listed.has(key) || (oldAbs && listed.has(changePathKey(oldAbs)))) {
844
+ if (!(Number(record.createdAt) >= since)) late.add(key);
845
+ continue;
846
+ }
847
+ const prior = groups.get(key) || (oldAbs ? groups.get(changePathKey(oldAbs)) : undefined);
848
+ if (prior) groups.delete(prior.key);
849
+ let origin = {};
850
+ if (prior) origin = { oldAbs: prior.oldAbs, oldRoot: prior.oldRoot };
851
+ else if (oldAbs) origin = { oldAbs, oldRoot: root };
852
+ groups.set(key, {
853
+ key,
854
+ abs,
855
+ root,
856
+ file,
857
+ first: prior?.first || file,
858
+ turns: (prior?.turns || 0) + 1,
859
+ ...origin,
860
+ });
861
+ }
862
+ }
863
+ const changes = [];
864
+ for (const group of groups.values()) {
865
+ const { file, first } = group;
866
+ if (group.turns > 1 && first.status === 'A' && file.status === 'D') continue;
867
+ const unit = {
868
+ key: group.key,
869
+ abs: group.abs,
870
+ root: group.root,
871
+ ...(group.oldAbs ? { oldAbs: group.oldAbs, oldRoot: group.oldRoot } : {}),
872
+ };
873
+ if (group.turns > 1) {
874
+ const status = first.status === 'A' ? 'A' : file.status;
875
+ changes.push({
876
+ ...unit,
877
+ change: partialChange(status, SEVERAL_TURNS_REASON, { baseline: 'missing', unmeasured: true }),
878
+ });
879
+ continue;
602
880
  }
881
+ const measured = await measureLegacyChangeFile(file, group.root, budget);
882
+ const recordPatch = patchOf(measured);
883
+ const counts = (Number(measured.additions) || 0) + (Number(measured.deletions) || 0);
884
+ changes.push({
885
+ ...unit,
886
+ change: {
887
+ status: file.status,
888
+ additions: Number(measured.additions) || 0,
889
+ deletions: Number(measured.deletions) || 0,
890
+ ...(measured.binary ? { binary: true } : {}),
891
+ text: '',
892
+ recordPatch,
893
+ recordName: slashPath(file.path),
894
+ partial: true,
895
+ baseline: 'missing',
896
+ ...(!recordPatch && counts === 0 && !measured.binary
897
+ ? { unmeasured: true, reason: clean(file.reason) || BASELINE_REASONS.missing }
898
+ : {}),
899
+ ...(!recordPatch && file.patchOmitted === true ? { patchOmitted: true } : {}),
900
+ },
901
+ });
603
902
  }
604
- return [...chains.values()];
903
+ return { changes, late };
605
904
  }
606
905
 
607
- /** Records whose emptiness the session review may vouch for: a baseline that
608
- * lists the files itself, or retained records (undo-aware). Without either
609
- * the history is absent, never authoritative. */
610
- const knownSources = new Set(['git', 'empty', 'tool']);
906
+ /** Files (`{ root, abs }`) the session claims but no baseline or record
907
+ * lists: the ones still present are listed without a diff. */
908
+ async function missingChanges(refs, { listed, covered }) {
909
+ const files = [];
910
+ for (const { root, abs } of refs) {
911
+ const key = changePathKey(abs);
912
+ if (listed.has(key) || covered(abs)) continue;
913
+ listed.add(key);
914
+ files.push({ key, abs, root });
915
+ }
916
+ const states = await mapLimit(files, READ_CONCURRENCY, (file) => currentFile(fileRef(file.root, file.abs)));
917
+ return files.map((file, index) =>
918
+ states[index].state === 'absent'
919
+ ? { ...file, gone: true }
920
+ : { ...file, change: partialChange('M', BASELINE_REASONS.missing, { baseline: 'missing' }) }
921
+ );
922
+ }
611
923
 
612
924
  /**
613
- * The session-wide review completed with the turns' recorded changes — the
614
- * source the transcript's turn-changes cards read — as ONE net change per
615
- * file, identical before and after a runtime restart. `base.source` says what
616
- * the baseline review could vouch for:
617
- * - `git`: the baseline is authoritative; only edits outside its root are added.
618
- * - `empty` / `tool`: no baseline lists files (a non-Git session, or a
619
- * session whose tracker holds nothing yet): the `tracked` records and the
620
- * running turn (`live`) supply them, never the tracker's single-turn patch.
621
- * - `absent` / `unavailable`: no ownership metadata survived; every retained
622
- * record supplies the files, and with no record the review is not complete.
623
- * A file the user undid is never listed; Reapply lists it again. Each listed
624
- * file carries an `id` (root + path); when two roots would show the same
625
- * relative name both show their absolute path.
925
+ * The session-wide review: the Git baseline review (`base`) completed with
926
+ * every file it does not cover — a non-Git root, or a file outside the Git
927
+ * root — measured from its first-seen state in `baselines` (the Lead's and
928
+ * its attributed workers' edits together) against the disk now. `base.source`
929
+ * says what the Git side vouches for (`git`: its root; `empty`: nothing was
930
+ * owned). Retained `records` only list files no baseline knows (a session
931
+ * recorded before baselines existed) and never make the result complete.
932
+ * `complete` is set only when every listed file was measured exactly from a
933
+ * first state the session itself recorded; otherwise the result is `partial`.
934
+ * A file the user undid is back at its first state and is not listed. Each
935
+ * listed file carries an `id` (its absolute path); two roots sharing a
936
+ * relative name both show their absolute path. A file's patch past the
937
+ * per-file cap, or past what the reply's total leaves, is left out
938
+ * (`patchOmitted`, reason PATCH_TOO_LARGE); `diffTooLarge` once nothing fit.
939
+ * `file` (an `id` or listed path) answers that one file's patch instead, up
940
+ * to the file-patch read's cap.
626
941
  */
627
- export async function mergeRecordedSessionChanges(base, records, live = null) {
628
- const source = base?.source || '';
942
+ export async function sessionChangeReview(base, { baselines = null, records = [], file: target = '' } = {}) {
943
+ const { source = '', reason: unavailableReason, owned, diffTooLarge: _baseTooLarge, ...kept } = base || {};
629
944
  const gitBacked = source === 'git';
630
- const cleared = source === 'tool' ? { ...base, files: [], patch: '' } : base;
631
- const retained = Array.isArray(records) ? records : [];
632
- const withLive =
633
- live && !retained.some((record) => record?.checkpointId === live.checkpointId) ? [...retained, live] : retained;
634
- let inRootKinds = new Set(['tracked']);
635
- if (gitBacked) inRootKinds = new Set();
636
- else if (source === 'absent' || source === 'unavailable') inRootKinds = new Set(['git', 'tracked']);
637
- const baseFiles = Array.isArray(cleared?.files) ? cleared.files : [];
638
- const basePatch = typeof cleared?.patch === 'string' ? cleared.patch : '';
639
945
  const baseRoot = clean(base?.root);
640
- const known = new Set();
641
- for (const file of baseFiles) {
642
- if (file?.path) known.add(absoluteKey(baseRoot, file.path));
643
- if (file?.oldPath) known.add(absoluteKey(baseRoot, file.oldPath));
644
- }
645
- const chains = recordChains(withLive, { inRootKinds }).filter((chain) => !known.has(chain.key));
646
- // A relative name shared by different roots (or with a baseline file) is
647
- // shown absolute, so each file stays distinct.
946
+ const covered = (abs) => gitBacked && Boolean(baseRoot) && insideDirectory(baseRoot, abs);
947
+ const listed = new Set((baselines?.files || []).map((entry) => entry.key));
948
+ let kinds = new Set(['tracked']);
949
+ if (gitBacked) kinds = new Set();
950
+ else if (source === 'absent' || source === 'unavailable') kinds = new Set(['git', 'tracked']);
951
+ // Baselines nothing is listed for, yet whose emptiness cannot be vouched for.
952
+ const unvouched = new Set();
953
+ // One line-diff budget for the whole review pass.
954
+ const diffBudget = createLineDiffBudget();
955
+ // A file's own patch read measures only the baselines of that file (by
956
+ // `id` or by its name under its root).
957
+ const targetKey = clean(target) ? changePathKey(target) : '';
958
+ const targeted = (entry) =>
959
+ changePathKey(entry.path) === targetKey ||
960
+ (Boolean(entry.root) &&
961
+ insideDirectory(entry.root, entry.path) &&
962
+ changePathKey(relative(entry.root, entry.path)) === targetKey);
963
+ const baselineUnits = baselines
964
+ ? await baselineChanges(baselines, covered, unvouched, diffBudget, { target: targetKey ? targeted : null })
965
+ : [];
966
+ // Units named but not measured still claim their names (see `show`).
967
+ const measured = baselineUnits.filter((unit) => !unit.nameOnly);
968
+ const legacy = await legacyChanges(Array.isArray(records) ? records : [], {
969
+ kinds,
970
+ listed,
971
+ covered,
972
+ since: Number(baselines?.createdAt) || Number.POSITIVE_INFINITY,
973
+ budget: diffBudget,
974
+ });
975
+ for (const change of legacy.changes) listed.add(change.key);
976
+ // Owned files of the non-Git root, and paths whose first state a clear in
977
+ // another process removed (the baselines are incomplete for them).
978
+ const refs = (baselines?.missing || []).map((entry) => ({ root: entry.root, abs: entry.path }));
979
+ if (!gitBacked && Array.isArray(owned)) {
980
+ for (const path of owned) refs.push({ root: baseRoot, abs: absolutePath(baseRoot, path) });
981
+ }
982
+ const missing = await missingChanges(refs, { listed, covered });
983
+ for (const change of measured) {
984
+ if (legacy.late.has(change.key)) change.change = { ...change.change, partial: true, reason: SEVERAL_TURNS_REASON };
985
+ }
986
+ const changes = [...measured, ...legacy.changes, ...missing.filter((file) => file.change)];
987
+ const baseFiles = gitBacked && Array.isArray(base.files) ? base.files : [];
988
+ const basePatch = gitBacked && typeof base.patch === 'string' ? base.patch : '';
989
+ // A relative name shared by different roots (or with a Git file) is shown
990
+ // absolute, so each file stays distinct.
991
+ const nameOf = (abs, root) => (root && insideDirectory(root, abs) ? slashPath(relative(root, abs)) : slashPath(abs));
648
992
  const owners = new Map();
649
- const claim = (rel, key) => {
650
- const name = changePathKey(rel);
651
- owners.set(name, new Set([...(owners.get(name) || []), key]));
993
+ const claim = (name, abs) => {
994
+ if (isOutsidePath(name)) return;
995
+ const key = changePathKey(name);
996
+ owners.set(key, new Set([...(owners.get(key) || []), changePathKey(abs)]));
652
997
  };
653
- for (const file of baseFiles) if (file?.path) claim(file.path, absoluteKey(baseRoot, file.path));
654
- for (const chain of chains) {
655
- const entry = chain.entries.at(-1);
656
- claim(entry.file.path, chain.key);
657
- const origin = chain.entries[0];
658
- if (origin.file.oldPath) claim(origin.file.oldPath, absoluteKey(origin.root, origin.file.oldPath));
659
- }
660
- const show = (root, rel) =>
661
- isOutsidePath(rel) || (owners.get(changePathKey(rel))?.size || 0) <= 1
662
- ? slashPath(rel)
663
- : slashPath(resolve(root, rel));
664
- const nets = [];
665
- for (const chain of chains) {
666
- const net = await netChain(chain, show);
667
- if (net) nets.push(net);
668
- }
669
- const { source: _source, reason: unavailableReason, ...kept } = cleared || {};
670
- const complete = knownSources.has(source) || withLive.length > 0;
671
- if (nets.length === 0) {
672
- // Nothing recorded to add: the baseline answer stands (an unavailable
673
- // baseline stays unavailable, never an invented empty set).
674
- if (cleared?.supported === false) return { ...kept, reason: unavailableReason };
675
- return { ...kept, ...(complete ? { complete: true } : {}) };
676
- }
677
- // The merged response stays bounded: past the budget a file is listed
678
- // without its diff.
679
- let budget = MERGED_PATCH_MAX_BYTES - Buffer.byteLength(basePatch, 'utf8');
680
- let truncated = false;
998
+ for (const file of baseFiles) if (file?.path) claim(file.path, resolve(baseRoot, file.path));
999
+ for (const change of [...changes, ...baselineUnits.filter((unit) => unit.nameOnly)]) {
1000
+ claim(nameOf(change.abs, change.root), change.abs);
1001
+ if (change.oldAbs) claim(nameOf(change.oldAbs, change.oldRoot), change.oldAbs);
1002
+ }
1003
+ const show = (abs, root) => {
1004
+ const name = nameOf(abs, root);
1005
+ return isOutsidePath(name) || (owners.get(changePathKey(name))?.size || 0) <= 1 ? name : slashPath(abs);
1006
+ };
1007
+ const rows = changes
1008
+ .map(({ abs, root, oldAbs, oldRoot, change }) => {
1009
+ const path = show(abs, root);
1010
+ const oldName = oldAbs ? show(oldAbs, oldRoot) : path;
1011
+ let patch = '';
1012
+ if (change.text) patch = nameMeasuredPatch(change.text, oldName, path);
1013
+ else if (change.recordPatch && change.recordName === path) patch = change.recordPatch;
1014
+ return {
1015
+ path,
1016
+ id: slashPath(abs),
1017
+ outside: !(root && insideDirectory(root, abs)),
1018
+ ...(oldName !== path ? { oldPath: oldName } : {}),
1019
+ status: change.status,
1020
+ additions: change.additions,
1021
+ deletions: change.deletions,
1022
+ ...(change.binary ? { binary: true } : {}),
1023
+ ...(change.reason ? { reason: change.reason } : {}),
1024
+ ...(change.baseline ? { baseline: change.baseline } : {}),
1025
+ ...(change.unmeasured ? { unmeasured: true } : {}),
1026
+ // Its patch is past what can be kept or read: listed too large.
1027
+ ...(change.patchOmitted === true && !patch ? { patchOmitted: true, reason: PATCH_TOO_LARGE } : {}),
1028
+ patch,
1029
+ };
1030
+ })
1031
+ .sort((left, right) => {
1032
+ if (left.path === right.path) return 0;
1033
+ return left.path < right.path ? -1 : 1;
1034
+ });
1035
+ if (clean(target)) {
1036
+ const key = changePathKey(target);
1037
+ const row = rows.find((entry) => changePathKey(entry.id) === key || changePathKey(entry.path) === key);
1038
+ if (row?.patchOmitted === true && !row.patch) return { ...filePatchReply(row.path, ''), ...patchOmittedFile({}) };
1039
+ return filePatchReply(row?.path || slashPath(target), row?.patch || '');
1040
+ }
1041
+ // The merged response stays bounded: a file past the per-file cap or the
1042
+ // total left is listed with its counts but without its diff.
1043
+ const budget = createReviewPatchBudget(MERGED_PATCH_MAX_BYTES - Buffer.byteLength(basePatch, 'utf8'));
681
1044
  const files = [];
682
1045
  const patches = [];
683
- for (const { patch, ...file } of nets) {
684
- const size = patch ? Buffer.byteLength(patch, 'utf8') : 0;
685
- if (patch && size <= budget) {
686
- budget -= size;
687
- patches.push(patch);
688
- } else if (patch) {
689
- truncated = true;
690
- files.push({ ...file, patchOmitted: true });
691
- continue;
692
- }
1046
+ for (const { patch, ...file } of rows) {
1047
+ if (patch && budget.admit(Buffer.byteLength(patch, 'utf8'))) patches.push(patch);
1048
+ else if (patch) Object.assign(file, patchOmittedFile(file));
693
1049
  files.push(file);
694
1050
  }
1051
+ // A baseline first seen after an older recorded edit (`late`) was not the
1052
+ // session's first state: even with no row it cannot vouch for the set.
1053
+ // Baselines the disk refused (`unsaved`) are known only to this process.
1054
+ const partial =
1055
+ baselines?.incomplete === true ||
1056
+ baselines?.unsaved === true ||
1057
+ unvouched.size > 0 ||
1058
+ legacy.late.size > 0 ||
1059
+ missing.length > 0 ||
1060
+ changes.some((change) => change.change.partial === true);
1061
+ const vouched = (gitBacked || source === 'empty' || Boolean(baselines)) && !partial;
1062
+ const all = [...baseFiles, ...files];
1063
+ // An unavailable Git baseline with nothing else to list stays unavailable.
1064
+ if (all.length === 0 && !vouched && kept.supported === false) return { ...kept, reason: unavailableReason };
1065
+ const patch = [basePatch, ...patches].filter(Boolean).join(basePatch.endsWith('\n') || !basePatch ? '' : '\n');
695
1066
  return {
696
1067
  ...kept,
697
1068
  supported: true,
698
- authoritative: true,
699
- complete,
700
- files: [...baseFiles, ...files],
701
- patch: [basePatch, ...patches].filter(Boolean).join(basePatch.endsWith('\n') || !basePatch ? '' : '\n'),
702
- patchTruncated: kept.patchTruncated === true || truncated,
703
- snapshotKind: cleared?.snapshotKind || 'session',
1069
+ // Emptiness is claimed only when it can be vouched for.
1070
+ authoritative: all.length > 0 || vouched,
1071
+ ...(vouched ? { complete: true } : {}),
1072
+ ...(partial ? { partial: true } : {}),
1073
+ ...(baselines?.unsaved === true ? { baselinesUnsaved: true } : {}),
1074
+ files: all,
1075
+ patch,
1076
+ patchTruncated: gitBacked && kept.patchTruncated === true,
1077
+ ...(reviewDiffTooLarge(all, patch) ? { diffTooLarge: true } : {}),
1078
+ snapshotKind: kept.snapshotKind || 'session',
704
1079
  };
705
1080
  }
706
1081
 
707
1082
  // ── Apply ───────────────────────────────────────────────────────────────────
708
1083
 
709
- /** Every path a stored patch touches must be exactly the recorded file's. */
1084
+ /** Every path a stored patch touches must be exactly the recorded file's.
1085
+ * Several sections are the file's own segments in write order: each starts
1086
+ * where the one before ended (a foreign edit in between stays out of both),
1087
+ * from the recorded old path to its current one. Resolves to the sections. */
710
1088
  function assertPatchMatchesFile(file, buffer) {
711
1089
  const sections = splitPatchSections(buffer);
712
- if (sections.length !== 1) throw new Error(`${file.path} has a malformed recorded patch`);
1090
+ if (sections.length === 0) throw new Error(`${file.path} has a malformed recorded patch`);
1091
+ const mismatch = () => new Error(`${file.path} has a recorded patch for a different path`);
1092
+ if (sections.length > 1) {
1093
+ let at = file.oldPath || file.path;
1094
+ for (const { oldPath, newPath } of sections) {
1095
+ if ([oldPath, newPath].some((path) => path !== null && !safeChangePath(path))) throw mismatch();
1096
+ if ((oldPath ?? newPath) !== at) throw mismatch();
1097
+ at = newPath ?? oldPath;
1098
+ }
1099
+ if (at !== file.path) throw mismatch();
1100
+ return sections;
1101
+ }
713
1102
  const { oldPath, newPath } = sections[0];
714
1103
  const expectedOld = file.status === 'A' ? null : file.oldPath || file.path;
715
1104
  const expectedNew = file.status === 'D' ? null : file.path;
@@ -717,10 +1106,9 @@ function assertPatchMatchesFile(file, buffer) {
717
1106
  [oldPath, expectedOld],
718
1107
  [newPath, expectedNew],
719
1108
  ]) {
720
- if (expected === null ? actual !== null : actual !== expected || !safeChangePath(actual)) {
721
- throw new Error(`${file.path} has a recorded patch for a different path`);
722
- }
1109
+ if (expected === null ? actual !== null : actual !== expected || !safeChangePath(actual)) throw mismatch();
723
1110
  }
1111
+ return sections;
724
1112
  }
725
1113
 
726
1114
  /** The recorded root must still be the directory (and, for a Git record, the
@@ -761,13 +1149,21 @@ export async function applyTurnChangeFiles(record, files, { reverse }) {
761
1149
  }
762
1150
  const buffer = changePatchBuffer(file);
763
1151
  if (!buffer?.length) throw new Error(`${file.path} has no recorded patch`);
764
- assertPatchMatchesFile(file, buffer);
765
- return buffer.at(-1) === 0x0a ? buffer : Buffer.concat([buffer, Buffer.from('\n')]);
1152
+ const sections = assertPatchMatchesFile(file, buffer);
1153
+ // `git apply` takes a file's sections in input order, each on the result
1154
+ // of the one before: oldest first forward, newest first for an Undo.
1155
+ return Buffer.concat(
1156
+ (reverse ? [...sections].reverse() : sections).map(({ patch }) =>
1157
+ patch.at(-1) === 0x0a ? patch : Buffer.concat([patch, Buffer.from('\n')])
1158
+ )
1159
+ );
766
1160
  });
767
- // A Git record replays with the repository's own line-ending conversion
768
- // (the semantics the shadow snapshot hashed with). An exact tracker patch
769
- // holds raw bytes, so nothing may convert them.
770
- const conversion = record.kind === 'git' ? [] : ['-c', 'core.autocrlf=false', '-c', 'core.eol=lf'];
1161
+ // An older Git record (worktree diff) replays with the repository's own
1162
+ // line-ending conversion, the semantics the shadow snapshot hashed with. A
1163
+ // record of the session's own tool bytes (`counts: 'own'`, any kind) holds
1164
+ // raw bytes, so nothing may convert them.
1165
+ const rawBytes = record.kind !== 'git' || record.counts === 'own';
1166
+ const conversion = rawBytes ? ['-c', 'core.autocrlf=false', '-c', 'core.eol=lf'] : [];
771
1167
  const args = [
772
1168
  ...conversion,
773
1169
  '-c',