mixdog 0.7.1

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 (404) hide show
  1. package/.claude-plugin/marketplace.json +31 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.gitattributes +34 -0
  4. package/.mcp.json +14 -0
  5. package/ARCHITECTURE.md +77 -0
  6. package/CHANGELOG.md +7 -0
  7. package/CONTRIBUTING.md +45 -0
  8. package/DATA-FLOW.md +79 -0
  9. package/LICENSE +21 -0
  10. package/README.md +389 -0
  11. package/SECURITY.md +138 -0
  12. package/UNINSTALL.md +112 -0
  13. package/agents/maintenance.md +5 -0
  14. package/agents/memory-classification.md +30 -0
  15. package/agents/scheduler-task.md +18 -0
  16. package/agents/webhook-handler.md +27 -0
  17. package/agents/worker.md +24 -0
  18. package/bin/bridge +133 -0
  19. package/bin/statusline-launcher.mjs +78 -0
  20. package/bin/statusline-lib.mjs +550 -0
  21. package/bin/statusline.mjs +607 -0
  22. package/bun.lock +802 -0
  23. package/commands/config.md +16 -0
  24. package/commands/doctor.md +13 -0
  25. package/commands/setup.md +17 -0
  26. package/defaults/cycle3-review-prompt.md +90 -0
  27. package/defaults/hidden-roles.json +65 -0
  28. package/defaults/memory-chunk-prompt.md +63 -0
  29. package/defaults/memory-promote-prompt.md +135 -0
  30. package/defaults/mixdog-config.template.json +27 -0
  31. package/defaults/user-workflow.json +8 -0
  32. package/defaults/user-workflow.md +12 -0
  33. package/hooks/hooks.json +73 -0
  34. package/hooks/lib/active-instance.cjs +77 -0
  35. package/hooks/lib/permission-evaluator.cjs +411 -0
  36. package/hooks/lib/permission-route.cjs +63 -0
  37. package/hooks/lib/permission-rules.cjs +170 -0
  38. package/hooks/lib/settings-loader.cjs +116 -0
  39. package/hooks/post-tool-use.cjs +84 -0
  40. package/hooks/pre-mcp-sandbox.cjs +158 -0
  41. package/hooks/pre-tool-subagent.cjs +253 -0
  42. package/hooks/session-start.cjs +1372 -0
  43. package/hooks/turn-timer.cjs +82 -0
  44. package/lib/claude-md-writer.cjs +386 -0
  45. package/lib/config-cjs.cjs +61 -0
  46. package/lib/hook-pipe-path.cjs +10 -0
  47. package/lib/keychain-cjs.cjs +263 -0
  48. package/lib/plugin-paths.cjs +61 -0
  49. package/lib/rules-builder.cjs +241 -0
  50. package/lib/text-utils.cjs +61 -0
  51. package/native/README.md +117 -0
  52. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  53. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  54. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  55. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  56. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  57. package/package.json +107 -0
  58. package/prompts/code-review.txt +16 -0
  59. package/prompts/security-audit.txt +17 -0
  60. package/rules/bridge/00-common.md +39 -0
  61. package/rules/bridge/20-skip-protocol.md +18 -0
  62. package/rules/bridge/30-explorer.md +33 -0
  63. package/rules/bridge/40-cycle1-agent.md +52 -0
  64. package/rules/bridge/41-cycle2-agent.md +62 -0
  65. package/rules/bridge/42-cycle3-agent.md +44 -0
  66. package/rules/lead/00-tool-lead.md +61 -0
  67. package/rules/lead/01-general.md +23 -0
  68. package/rules/lead/02-channels.md +49 -0
  69. package/rules/lead/03-team.md +27 -0
  70. package/rules/lead/04-workflow.md +20 -0
  71. package/rules/shared/00-language.md +14 -0
  72. package/rules/shared/01-tool.md +138 -0
  73. package/scripts/bootstrap.mjs +184 -0
  74. package/scripts/bridge-unify-smoke.mjs +308 -0
  75. package/scripts/build-runtime-linux.sh +348 -0
  76. package/scripts/build-runtime-macos.sh +217 -0
  77. package/scripts/build-runtime-windows.ps1 +242 -0
  78. package/scripts/builtin-utils-smoke.mjs +392 -0
  79. package/scripts/check-json.mjs +45 -0
  80. package/scripts/check-syntax-changed.mjs +102 -0
  81. package/scripts/check-syntax.mjs +58 -0
  82. package/scripts/code-graph-batch.test.mjs +33 -0
  83. package/scripts/config-preserve-smoke.mjs +180 -0
  84. package/scripts/doctor.mjs +484 -0
  85. package/scripts/edit-normalize-fuzz.mjs +130 -0
  86. package/scripts/edit-normalize-smoke.mjs +401 -0
  87. package/scripts/edit-operation-smoke.mjs +369 -0
  88. package/scripts/edit2-smoke.mjs +63 -0
  89. package/scripts/fuzzy-e2e.mjs +28 -0
  90. package/scripts/fuzzy-smoke.mjs +26 -0
  91. package/scripts/generate-runtime-manifest.mjs +166 -0
  92. package/scripts/guard-smoke.mjs +66 -0
  93. package/scripts/hidden-role-schema-smoke.mjs +162 -0
  94. package/scripts/hook-routing-smoke.mjs +29 -0
  95. package/scripts/inject-input.ps1 +204 -0
  96. package/scripts/io-complex-smoke.mjs +667 -0
  97. package/scripts/io-explore-bench.mjs +424 -0
  98. package/scripts/io-guardrails-smoke.mjs +205 -0
  99. package/scripts/io-mini-bench-baseline.json +11 -0
  100. package/scripts/io-mini-bench.mjs +216 -0
  101. package/scripts/io-route-harness.mjs +933 -0
  102. package/scripts/io-telemetry-report.mjs +691 -0
  103. package/scripts/mutation-bench.mjs +564 -0
  104. package/scripts/mutation-io-smoke.mjs +1081 -0
  105. package/scripts/native-patch-bridge-smoke.mjs +288 -0
  106. package/scripts/native-patch-smoke.mjs +304 -0
  107. package/scripts/patch-interior-context-smoke.mjs +49 -0
  108. package/scripts/patch-newline-utf8-smoke.mjs +157 -0
  109. package/scripts/perf-hook-smoke.mjs +71 -0
  110. package/scripts/permission-eval-smoke.mjs +426 -0
  111. package/scripts/prep-patch.mjs +53 -0
  112. package/scripts/prep-shim.mjs +96 -0
  113. package/scripts/provider-cache-smoke.mjs +687 -0
  114. package/scripts/report-runtime-health.mjs +132 -0
  115. package/scripts/run-mcp.mjs +1547 -0
  116. package/scripts/salvage-v4a-shatter.test.mjs +58 -0
  117. package/scripts/scoped-cache-io-smoke.mjs +103 -0
  118. package/scripts/shell-policy-round3-smoke.mjs +46 -0
  119. package/scripts/smoke-runtime-negative.ps1 +100 -0
  120. package/scripts/smoke-runtime-negative.sh +95 -0
  121. package/scripts/stall-policy-smoke.mjs +50 -0
  122. package/scripts/start-memory-worker.mjs +23 -0
  123. package/scripts/statusline-launcher-smoke.mjs +82 -0
  124. package/scripts/stress-atomic-write.mjs +1028 -0
  125. package/scripts/test-config-rmw-restore.mjs +122 -0
  126. package/scripts/test-fault-inject.mjs +164 -0
  127. package/scripts/test-large-file.mjs +174 -0
  128. package/scripts/tool-edge-smoke.mjs +209 -0
  129. package/scripts/uninstall.mjs +201 -0
  130. package/scripts/webhook-selfheal-smoke.mjs +29 -0
  131. package/scripts/write-overwrite-guard-smoke.mjs +56 -0
  132. package/server-main.mjs +3055 -0
  133. package/server.mjs +468 -0
  134. package/setup/config-merge.mjs +254 -0
  135. package/setup/install.mjs +120 -0
  136. package/setup/launch-core.mjs +507 -0
  137. package/setup/launch.mjs +101 -0
  138. package/setup/setup-server.mjs +3206 -0
  139. package/setup/setup.html +3693 -0
  140. package/skills/retro-skill-proposer/SKILL.md +92 -0
  141. package/skills/schedule-add/SKILL.md +77 -0
  142. package/skills/setup/SKILL.md +346 -0
  143. package/skills/webhook-add/SKILL.md +81 -0
  144. package/src/agent/bridge-stall-watchdog.mjs +337 -0
  145. package/src/agent/index.mjs +2138 -0
  146. package/src/agent/orchestrator/activity-bus.mjs +38 -0
  147. package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
  148. package/src/agent/orchestrator/bridge-retry.mjs +220 -0
  149. package/src/agent/orchestrator/bridge-trace.mjs +583 -0
  150. package/src/agent/orchestrator/cache-mtime.mjs +58 -0
  151. package/src/agent/orchestrator/config.mjs +358 -0
  152. package/src/agent/orchestrator/context/collect.mjs +651 -0
  153. package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
  154. package/src/agent/orchestrator/drain-registry.mjs +50 -0
  155. package/src/agent/orchestrator/explore-validator.mjs +8 -0
  156. package/src/agent/orchestrator/internal-roles.mjs +118 -0
  157. package/src/agent/orchestrator/internal-tools.mjs +88 -0
  158. package/src/agent/orchestrator/jobs.mjs +116 -0
  159. package/src/agent/orchestrator/mcp/client.mjs +364 -0
  160. package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
  161. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
  162. package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
  163. package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
  164. package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
  165. package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
  166. package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
  167. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
  168. package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
  169. package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
  170. package/src/agent/orchestrator/providers/registry.mjs +192 -0
  171. package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
  172. package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
  173. package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
  174. package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
  175. package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
  176. package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
  177. package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
  178. package/src/agent/orchestrator/session/cache/util.mjs +49 -0
  179. package/src/agent/orchestrator/session/loop.mjs +1478 -0
  180. package/src/agent/orchestrator/session/manager.mjs +1975 -0
  181. package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
  182. package/src/agent/orchestrator/session/result-classification.mjs +65 -0
  183. package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
  184. package/src/agent/orchestrator/session/store.mjs +624 -0
  185. package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
  186. package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
  187. package/src/agent/orchestrator/session/trim.mjs +491 -0
  188. package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
  189. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
  190. package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
  191. package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
  192. package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
  193. package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
  194. package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
  195. package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
  196. package/src/agent/orchestrator/stall-policy.mjs +195 -0
  197. package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
  198. package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
  199. package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
  200. package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
  201. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
  202. package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
  203. package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
  204. package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
  205. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
  206. package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
  207. package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
  208. package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
  209. package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
  210. package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
  211. package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
  212. package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
  213. package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
  214. package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
  215. package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
  216. package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
  217. package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
  218. package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
  219. package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
  220. package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
  221. package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
  222. package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
  223. package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
  224. package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
  225. package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
  226. package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
  227. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
  228. package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
  229. package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
  230. package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
  231. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
  232. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
  233. package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
  234. package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
  235. package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
  236. package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
  237. package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
  238. package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
  239. package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
  240. package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
  241. package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
  242. package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
  243. package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
  244. package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
  245. package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
  246. package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
  247. package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
  248. package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
  249. package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
  250. package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
  251. package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
  252. package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
  253. package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
  254. package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
  255. package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
  256. package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
  257. package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
  258. package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
  259. package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
  260. package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
  261. package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
  262. package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
  263. package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
  264. package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
  265. package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
  266. package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
  267. package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
  268. package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
  269. package/src/agent/orchestrator/tools/builtin.mjs +500 -0
  270. package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
  271. package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
  272. package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
  273. package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
  274. package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
  275. package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
  276. package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
  277. package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
  278. package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
  279. package/src/agent/orchestrator/tools/host-input.mjs +204 -0
  280. package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
  281. package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
  282. package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
  283. package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
  284. package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
  285. package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
  286. package/src/agent/orchestrator/tools/patch.mjs +2754 -0
  287. package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
  288. package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
  289. package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
  290. package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
  291. package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
  292. package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
  293. package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
  294. package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
  295. package/src/agent/orchestrator/workflow-store.mjs +93 -0
  296. package/src/agent/tool-defs.mjs +103 -0
  297. package/src/channels/backends/discord.mjs +784 -0
  298. package/src/channels/data/voice-runtime-manifest.json +138 -0
  299. package/src/channels/index.mjs +3229 -0
  300. package/src/channels/lib/cli-worker-host.mjs +12 -0
  301. package/src/channels/lib/config-lock.mjs +13 -0
  302. package/src/channels/lib/config.mjs +292 -0
  303. package/src/channels/lib/drop-trace.mjs +71 -0
  304. package/src/channels/lib/event-pipeline.mjs +81 -0
  305. package/src/channels/lib/event-queue.mjs +345 -0
  306. package/src/channels/lib/executor.mjs +168 -0
  307. package/src/channels/lib/format.mjs +188 -0
  308. package/src/channels/lib/holidays.mjs +138 -0
  309. package/src/channels/lib/hook-pipe-server.mjs +802 -0
  310. package/src/channels/lib/interaction-workflows.mjs +184 -0
  311. package/src/channels/lib/memory-client.mjs +149 -0
  312. package/src/channels/lib/output-forwarder.mjs +765 -0
  313. package/src/channels/lib/runtime-paths.mjs +479 -0
  314. package/src/channels/lib/scheduler.mjs +723 -0
  315. package/src/channels/lib/session-control.mjs +36 -0
  316. package/src/channels/lib/session-discovery.mjs +103 -0
  317. package/src/channels/lib/settings.mjs +11 -0
  318. package/src/channels/lib/state-file.mjs +68 -0
  319. package/src/channels/lib/status-snapshot.mjs +219 -0
  320. package/src/channels/lib/tool-format.mjs +140 -0
  321. package/src/channels/lib/transcript-discovery.mjs +195 -0
  322. package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
  323. package/src/channels/lib/webhook.mjs +1179 -0
  324. package/src/channels/lib/whisper-server.mjs +477 -0
  325. package/src/channels/tool-defs.mjs +170 -0
  326. package/src/daemon/host.mjs +118 -0
  327. package/src/daemon/mcp-transport.mjs +47 -0
  328. package/src/daemon/session.mjs +100 -0
  329. package/src/daemon/thin-client.mjs +71 -0
  330. package/src/daemon/transport.mjs +163 -0
  331. package/src/memory/data/runtime-manifest.json +40 -0
  332. package/src/memory/index.mjs +3305 -0
  333. package/src/memory/lib/agent-ipc.mjs +93 -0
  334. package/src/memory/lib/bridge-trace-queries.mjs +120 -0
  335. package/src/memory/lib/core-memory-store.mjs +330 -0
  336. package/src/memory/lib/embedding-provider.mjs +269 -0
  337. package/src/memory/lib/embedding-worker.mjs +323 -0
  338. package/src/memory/lib/llm-worker-host.mjs +17 -0
  339. package/src/memory/lib/memory-cycle.mjs +11 -0
  340. package/src/memory/lib/memory-cycle1.mjs +641 -0
  341. package/src/memory/lib/memory-cycle2.mjs +1284 -0
  342. package/src/memory/lib/memory-cycle3.mjs +540 -0
  343. package/src/memory/lib/memory-embed.mjs +299 -0
  344. package/src/memory/lib/memory-extraction.mjs +5 -0
  345. package/src/memory/lib/memory-maintenance-store.mjs +32 -0
  346. package/src/memory/lib/memory-ops-policy.mjs +190 -0
  347. package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
  348. package/src/memory/lib/memory-recall-read-query.mjs +7 -0
  349. package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
  350. package/src/memory/lib/memory-recall-store.mjs +621 -0
  351. package/src/memory/lib/memory-retrievers.mjs +112 -0
  352. package/src/memory/lib/memory-score.mjs +71 -0
  353. package/src/memory/lib/memory-text-utils.mjs +58 -0
  354. package/src/memory/lib/memory.mjs +412 -0
  355. package/src/memory/lib/model-profile.mjs +85 -0
  356. package/src/memory/lib/pg/adapter.mjs +308 -0
  357. package/src/memory/lib/pg/process.mjs +360 -0
  358. package/src/memory/lib/pg/supervisor.mjs +396 -0
  359. package/src/memory/lib/project-id-resolver.mjs +86 -0
  360. package/src/memory/lib/runtime-fetcher.mjs +442 -0
  361. package/src/memory/lib/trace-store.mjs +728 -0
  362. package/src/memory/tool-defs.mjs +79 -0
  363. package/src/search/index.mjs +1173 -0
  364. package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
  365. package/src/search/lib/backends/exa.mjs +50 -0
  366. package/src/search/lib/backends/firecrawl.mjs +61 -0
  367. package/src/search/lib/backends/gemini-api.mjs +83 -0
  368. package/src/search/lib/backends/grok-oauth.mjs +86 -0
  369. package/src/search/lib/backends/index.mjs +150 -0
  370. package/src/search/lib/backends/openai-api.mjs +144 -0
  371. package/src/search/lib/backends/openai-oauth.mjs +98 -0
  372. package/src/search/lib/backends/openai-web-search.mjs +76 -0
  373. package/src/search/lib/backends/tavily.mjs +55 -0
  374. package/src/search/lib/backends/xai-api.mjs +113 -0
  375. package/src/search/lib/cache.mjs +131 -0
  376. package/src/search/lib/config.mjs +192 -0
  377. package/src/search/lib/formatter.mjs +115 -0
  378. package/src/search/lib/provider-usage.mjs +67 -0
  379. package/src/search/lib/providers.mjs +47 -0
  380. package/src/search/lib/search-intent.mjs +109 -0
  381. package/src/search/lib/setup-handler.mjs +261 -0
  382. package/src/search/lib/state.mjs +201 -0
  383. package/src/search/lib/web-tools.mjs +1207 -0
  384. package/src/search/tool-defs.mjs +83 -0
  385. package/src/setup/defender-exclusion.mjs +183 -0
  386. package/src/shared/abort-controller.mjs +15 -0
  387. package/src/shared/atomic-file.mjs +420 -0
  388. package/src/shared/config.mjs +350 -0
  389. package/src/shared/daemon-recycle.mjs +108 -0
  390. package/src/shared/disable-claude-builtins.mjs +88 -0
  391. package/src/shared/err-text.mjs +12 -0
  392. package/src/shared/llm/cost.mjs +66 -0
  393. package/src/shared/llm/http-agent.mjs +123 -0
  394. package/src/shared/llm/index.mjs +41 -0
  395. package/src/shared/llm/pid-cleanup.mjs +27 -0
  396. package/src/shared/llm/usage-log.mjs +47 -0
  397. package/src/shared/plugin-paths.mjs +58 -0
  398. package/src/shared/schedules-store.mjs +70 -0
  399. package/src/shared/seed.mjs +119 -0
  400. package/src/shared/user-cwd.mjs +213 -0
  401. package/src/shared/user-data-guard.mjs +238 -0
  402. package/src/status/aggregator.mjs +584 -0
  403. package/src/status/server.mjs +413 -0
  404. package/tools.json +1653 -0
@@ -0,0 +1,2754 @@
1
+ // apply_patch — one-turn multi-file edits from a unified diff.
2
+ //
3
+ // Typical Lead workflow without this tool is `read` → `edit` per file, which
4
+ // costs N+1 turns for an N-file refactor. A unified diff already encodes
5
+ // every hunk's surrounding context, so we can apply the whole patch
6
+ // server-side and skip the read round-trips entirely.
7
+ //
8
+ // Backend: NATIVE-ONLY. Every supported case is dispatched to the
9
+ // mixdog-patch Rust engine via the persistent stdio server. There is NO
10
+ // JS apply fallback: unsupported / unsafe input returns a clean Error
11
+ // string ("Error: …"), never silently degrades to a different engine.
12
+ // `parsePatch(str)` splits a multi-file diff into one object per file
13
+ // with `{oldFileName, newFileName, hunks}`; the parsed entries are
14
+ // consulted only to derive display path/lines stats and to pre-validate
15
+ // path-escape safety before dispatch.
16
+ //
17
+ // Safety model:
18
+ // - No separate read gate. Hunk context lines are themselves the
19
+ // match proof — if they don't match, the native engine
20
+ // rejects the hunk and nothing is written.
21
+ // - Path-escape pre-validator throws on out-of-base `..` segments and
22
+ // symlink/junction escapes (realpath verifies the target stays inside
23
+ // the basePath even when an intermediate symlink points outside).
24
+ // - `reject_partial:true` (default) — file-batch atomic. Native engine
25
+ // errors out before touching disk.
26
+ // - `reject_partial:false` — file-level isolation. Native engine
27
+ // responds with OK_PARTIAL plus a hex-encoded failures payload that
28
+ // the JS side surfaces per-entry.
29
+
30
+ import { existsSync, readFileSync, realpathSync, statSync, mkdirSync } from 'node:fs';
31
+ import { unlink } from 'node:fs/promises';
32
+ import { spawn } from 'node:child_process';
33
+ import { createInterface } from 'node:readline';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { resolve as pathResolve, relative as pathRelative, isAbsolute, dirname as pathDirname, join as pathJoin } from 'node:path';
36
+ import { performance } from 'node:perf_hooks';
37
+ import { parsePatch } from 'diff';
38
+ import { getAbortSignalForSession } from '../session/abort-lookup.mjs';
39
+ import {
40
+ normalizeInputPath,
41
+ normalizeOutputPath,
42
+ resolveAgainstCwd,
43
+ invalidateBuiltinResultCache,
44
+ recordReadSnapshotForPath,
45
+ clearReadSnapshotForPath,
46
+ withBuiltinPathLocks,
47
+ } from './builtin.mjs';
48
+ import { withAdvisoryLocks } from './builtin/advisory-lock.mjs';
49
+ import { markCodeGraphDirtyPaths } from './code-graph.mjs';
50
+ import { wrapMutationRouteOutput } from './mutation-planner.mjs';
51
+ import { getPluginData } from '../config.mjs';
52
+ import { ensurePatchBinary, findCachedPatchBinary } from './patch-binary-fetcher.mjs';
53
+ import {
54
+ rawContentCacheGet,
55
+ rawContentCacheSet,
56
+ } from './builtin/cache-layers.mjs';
57
+ import { atomicWrite } from './builtin/atomic-write.mjs';
58
+ import { assertPathReachable, assertPathsReachable } from './builtin/fs-reachability.mjs';
59
+ export { PATCH_TOOL_DEFS } from './patch-tool-defs.mjs';
60
+
61
+ const DEV_NULL = /^\/dev\/null$/;
62
+ const V4A_EOF_MARKER = '*** End of File';
63
+ const V4A_MOVE_TO_PREFIX = '*** Move to:';
64
+ const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT
65
+ || pathResolve(pathDirname(fileURLToPath(import.meta.url)), '../../../..');
66
+ const NATIVE_PATCH_DEFAULT_BIN = pathJoin(
67
+ PLUGIN_ROOT,
68
+ 'native/mixdog-patch/target/release',
69
+ process.platform === 'win32' ? 'mixdog-patch.exe' : 'mixdog-patch',
70
+ );
71
+ let _nativePatchServer = null;
72
+ let _nativePatchPrewarmTimer = null;
73
+ let _nativeEditServer = null;
74
+
75
+ function nativePatchMode() {
76
+ return String(process.env.MIXDOG_PATCH_NATIVE || 'auto').toLowerCase();
77
+ }
78
+
79
+ function nativePatchEnabled() {
80
+ return !/^(0|false|no|off|js|legacy)$/i.test(nativePatchMode());
81
+ }
82
+
83
+ function nativePatchTraceEnabled() {
84
+ return /^(1|true|yes)$/i.test(process.env.MIXDOG_PATCH_NATIVE_TRACE || '');
85
+ }
86
+
87
+ function ioTraceEnabled() {
88
+ return /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_IO_TRACE || ''));
89
+ }
90
+
91
+ function ioTrace(event, fields = {}) {
92
+ if (!ioTraceEnabled()) return;
93
+ try {
94
+ process.stderr.write(`[io-trace] ${JSON.stringify({ event, ts: Date.now(), ...fields })}\n`);
95
+ } catch {}
96
+ }
97
+
98
+ function patchTraceEnabled() {
99
+ return ioTraceEnabled()
100
+ || nativePatchTraceEnabled()
101
+ || /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_PATCH_TRACE || ''));
102
+ }
103
+
104
+ function nativePatchPrewarmEnabled() {
105
+ if (!nativePatchEnabled()) return false;
106
+ if (process.env.MIXDOG_PATCH_NATIVE_BIN && !existsSync(nativePatchBinPath())) return false;
107
+ return !/^(0|false|no)$/i.test(process.env.MIXDOG_PATCH_NATIVE_PREWARM || '');
108
+ }
109
+
110
+ function nativePatchPersistent() {
111
+ return /^(1|true|yes|server|persistent)$/i.test(nativePatchMode());
112
+ }
113
+
114
+ function nativePatchBinPath() {
115
+ if (process.env.MIXDOG_PATCH_NATIVE_BIN) return process.env.MIXDOG_PATCH_NATIVE_BIN;
116
+ // Local cargo build first, then a fetched/cached prebuilt; absence is
117
+ // a hard error at dispatch (no JS fallback in native-only mode).
118
+ if (existsSync(NATIVE_PATCH_DEFAULT_BIN)) return NATIVE_PATCH_DEFAULT_BIN;
119
+ return findCachedPatchBinary(getPluginData()) || NATIVE_PATCH_DEFAULT_BIN;
120
+ }
121
+
122
+ async function ensureNativePatchBinaryAvailable() {
123
+ if (!nativePatchEnabled()) {
124
+ throw new Error('apply_patch: native engine disabled via MIXDOG_PATCH_NATIVE; set it to "auto" or "1" to apply patches.');
125
+ }
126
+ const current = nativePatchBinPath();
127
+ if (existsSync(current)) return current;
128
+ if (process.env.MIXDOG_PATCH_NATIVE_BIN) {
129
+ throw new Error(`apply_patch: native patch binary not found at MIXDOG_PATCH_NATIVE_BIN=${current}.`);
130
+ }
131
+ try {
132
+ const fetched = await ensurePatchBinary(getPluginData());
133
+ if (fetched && existsSync(fetched)) return fetched;
134
+ } catch (err) {
135
+ throw new Error(`apply_patch: native patch binary unavailable — ${err?.message || String(err)}`);
136
+ }
137
+ const resolved = nativePatchBinPath();
138
+ if (existsSync(resolved)) return resolved;
139
+ throw new Error(`apply_patch: native patch binary not found at ${resolved}.`);
140
+ }
141
+
142
+ // Decode the hex-encoded failures payload that accompanies OK_PARTIAL:
143
+ // the Rust side emits utf-8 bytes (`<path>\t<reason>` records joined by
144
+ // `\n`) hex-encoded so they can ride the tab-separated response line
145
+ // without escaping. An empty / unparseable payload becomes an empty list
146
+ // so a missing field never crashes the caller.
147
+ function decodeNativeFailures(hexPayload) {
148
+ if (typeof hexPayload !== 'string' || hexPayload.length === 0) return [];
149
+ if (!/^[0-9a-fA-F]+$/.test(hexPayload) || hexPayload.length % 2 !== 0) return [];
150
+ let text = '';
151
+ try { text = Buffer.from(hexPayload, 'hex').toString('utf-8'); }
152
+ catch { return []; }
153
+ const out = [];
154
+ for (const raw of text.split('\n')) {
155
+ if (!raw) continue;
156
+ const tab = raw.indexOf('\t');
157
+ if (tab === -1) out.push({ path: '', reason: raw });
158
+ else out.push({ path: raw.slice(0, tab), reason: raw.slice(tab + 1) });
159
+ }
160
+ return out;
161
+ }
162
+
163
+ class NativePatchServer {
164
+ constructor(binPath) {
165
+ this.binPath = binPath;
166
+ // windowsHide: mixdog-patch.exe is a console binary; without this each spawn
167
+ // flashes an empty console window on Windows. Especially visible now that the
168
+ // idle watchdog exits the server and it respawns on the next request.
169
+ this.child = spawn(binPath, ['--server'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
170
+ this.stderr = '';
171
+ this.lines = [];
172
+ this.waiters = [];
173
+ this.exited = false;
174
+ this.child.stderr.setEncoding('utf8');
175
+ this.child.stderr.on('data', (chunk) => { this.stderr += chunk; });
176
+ this.rl = createInterface({ input: this.child.stdout });
177
+ this.rl.on('line', (line) => {
178
+ const waiter = this.waiters.shift();
179
+ if (waiter) waiter.resolve(line);
180
+ else this.lines.push(line);
181
+ });
182
+ this.child.on('exit', (code, signal) => {
183
+ this.exited = true;
184
+ const err = new Error(`native patch server exited code=${code} signal=${signal} stderr=${this.stderr}`);
185
+ for (const waiter of this.waiters.splice(0)) waiter.reject(err);
186
+ try { this.rl.close(); } catch {}
187
+ });
188
+ }
189
+
190
+ abort(signal) {
191
+ const err = new Error(signal?.reason?.message || signal?.reason || 'native patch aborted');
192
+ err.name = 'AbortError';
193
+ if (_nativePatchServer === this) _nativePatchServer = null;
194
+ for (const waiter of this.waiters.splice(0)) waiter.reject(err);
195
+ try { this.child.kill('SIGTERM'); } catch {}
196
+ return err;
197
+ }
198
+
199
+ nextLine() {
200
+ if (this.lines.length > 0) return Promise.resolve(this.lines.shift());
201
+ if (this.exited) return Promise.reject(new Error(`native patch server already exited: ${this.stderr}`));
202
+ return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
203
+ }
204
+
205
+ ref() {
206
+ try { this.child.ref(); } catch {}
207
+ try { this.child.stdin.ref?.(); } catch {}
208
+ try { this.child.stdout.ref?.(); } catch {}
209
+ try { this.child.stderr.ref?.(); } catch {}
210
+ }
211
+
212
+ unref() {
213
+ try { this.child.unref(); } catch {}
214
+ try { this.child.stdin.unref?.(); } catch {}
215
+ try { this.child.stdout.unref?.(); } catch {}
216
+ try { this.child.stderr.unref?.(); } catch {}
217
+ }
218
+
219
+ async ping() {
220
+ this.ref();
221
+ const linePromise = this.nextLine();
222
+ this.child.stdin.write('PING\n');
223
+ const line = await linePromise;
224
+ if (line !== 'OK\tPONG') {
225
+ throw new Error(`native patch server ping failed: ${line || 'no native response'}`);
226
+ }
227
+ }
228
+
229
+ async apply(basePath, patchText, { fuzz = 2, rejectPartial = true, dryRun = false, signal = null } = {}) {
230
+ this.ref();
231
+ if (signal?.aborted) {
232
+ const err = new Error(signal.reason?.message || signal.reason || 'native patch aborted');
233
+ err.name = 'AbortError';
234
+ throw err;
235
+ }
236
+ const started = performance.now();
237
+ const baseBuf = Buffer.from(basePath, 'utf8');
238
+ const patchBuf = Buffer.from(patchText, 'utf8');
239
+ const linePromise = this.nextLine();
240
+ if (signal) linePromise.catch(() => {});
241
+ let abortListener = null;
242
+ const abortPromise = signal ? new Promise((_, reject) => {
243
+ abortListener = () => {
244
+ reject(this.abort(signal));
245
+ };
246
+ signal.addEventListener('abort', abortListener, { once: true });
247
+ }) : null;
248
+ // 7-token APPLY protocol: APPLY <base_len> <patch_len> <timing> <dry_run> <fuzz> <reject_partial>
249
+ // - timing=1 keeps the server emitting per-phase ms fields
250
+ // - dry_run=1 validates without writing; useful for tests and explicit callers
251
+ // - fuzz=0 means strict context match; fuzz=2 absorbs minor outer-context drift and context trailing spaces/tabs
252
+ // - reject_partial=0 unlocks file-level isolation (OK_PARTIAL response)
253
+ const fuzzTok = Number.isFinite(fuzz) && fuzz >= 0 ? Math.floor(fuzz) : 2;
254
+ const rpTok = rejectPartial ? 1 : 0;
255
+ const dryTok = dryRun ? 1 : 0;
256
+ this.child.stdin.write(`APPLY ${baseBuf.length} ${patchBuf.length} 1 ${dryTok} ${fuzzTok} ${rpTok}\n`);
257
+ this.child.stdin.write(baseBuf);
258
+ this.child.stdin.write(patchBuf);
259
+ let line;
260
+ try {
261
+ line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
262
+ } finally {
263
+ if (abortListener) {
264
+ try { signal.removeEventListener('abort', abortListener); } catch {}
265
+ }
266
+ }
267
+ if (!line) throw new Error('no native response');
268
+ if (line.startsWith('ERR\t')) throw new Error(line.slice(4));
269
+ const okFull = line.startsWith('OK\t');
270
+ const okPartial = line.startsWith('OK_PARTIAL\t');
271
+ if (!okFull && !okPartial) throw new Error(line);
272
+ const fields = line.split('\t');
273
+ // fields[0] = "OK" | "OK_PARTIAL".
274
+ // OK layout: <files> <readMs> <applyMs> <writeMs> <totalMs> <hashMs> <contentHashes>
275
+ // OK_PARTIAL layout: <files> <failed> <readMs> <applyMs> <writeMs> <totalMs> <hashMs> <contentHashes> <hexFailures>
276
+ // The OK_PARTIAL line carries an extra <failed> count between <files>
277
+ // and the timing block, plus a trailing <hexFailures> column — keep
278
+ // the two decodes separate so SKIP failure counts stay accurate.
279
+ let files; let readMs; let applyMs; let writeMs; let totalMs; let hashMs;
280
+ let contentHashesRaw; let hexFailures;
281
+ if (okPartial) {
282
+ files = fields[1];
283
+ // fields[2] = <failed> count; the JS layer already derives a failure
284
+ // count from decodeNativeFailures(hexFailures), so skip the raw cell.
285
+ readMs = fields[3];
286
+ applyMs = fields[4];
287
+ writeMs = fields[5];
288
+ totalMs = fields[6];
289
+ hashMs = fields[7];
290
+ contentHashesRaw = fields[8];
291
+ hexFailures = fields[9];
292
+ } else {
293
+ files = fields[1];
294
+ readMs = fields[2];
295
+ applyMs = fields[3];
296
+ writeMs = fields[4];
297
+ totalMs = fields[5];
298
+ hashMs = fields[6];
299
+ contentHashesRaw = fields[7];
300
+ }
301
+ const contentHashes = String(contentHashesRaw || '')
302
+ .split(',')
303
+ .filter((value) => value.length > 0)
304
+ .map((value) => (/^[a-f0-9]{64}$/i.test(value) ? value.toLowerCase() : null));
305
+ const failures = okPartial ? decodeNativeFailures(hexFailures) : [];
306
+ return {
307
+ partial: okPartial,
308
+ files: Number(files) || 0,
309
+ readMs: Number(readMs) || 0,
310
+ applyMs: Number(applyMs) || 0,
311
+ writeMs: Number(writeMs) || 0,
312
+ hashMs: Number(hashMs) || 0,
313
+ totalMs: Number(totalMs) || 0,
314
+ contentHashes,
315
+ contentHash: contentHashes.length === 1 ? contentHashes[0] : null,
316
+ failures,
317
+ roundtripMs: performance.now() - started,
318
+ };
319
+ }
320
+
321
+ // EDIT protocol client: invariant-safe char-indexed edit. Mirrors apply()'s
322
+ // abort/await-line handling. EDIT <path_len> <old_len> <new_len> <replace_all>
323
+ // <dry_run> then path+old+new bytes; response is the 8-field OK line with the
324
+ // matched tier.
325
+ async edit(fullPath, oldBuf, newBuf, { replaceAll = false, dryRun = false, signal = null } = {}) {
326
+ this.ref();
327
+ if (signal?.aborted) {
328
+ const err = new Error(signal.reason?.message || signal.reason || 'native edit aborted');
329
+ err.name = 'AbortError';
330
+ throw err;
331
+ }
332
+ const started = performance.now();
333
+ const pathBuf = Buffer.from(fullPath, 'utf8');
334
+ const linePromise = this.nextLine();
335
+ if (signal) linePromise.catch(() => {});
336
+ let abortListener = null;
337
+ const abortPromise = signal ? new Promise((_, reject) => {
338
+ abortListener = () => { reject(this.abort(signal)); };
339
+ signal.addEventListener('abort', abortListener, { once: true });
340
+ }) : null;
341
+ this.child.stdin.write(
342
+ `EDIT ${pathBuf.length} ${oldBuf.length} ${newBuf.length} ${replaceAll ? 1 : 0} ${dryRun ? 1 : 0}\n`,
343
+ );
344
+ this.child.stdin.write(pathBuf);
345
+ this.child.stdin.write(oldBuf);
346
+ this.child.stdin.write(newBuf);
347
+ let line;
348
+ try {
349
+ line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
350
+ } finally {
351
+ if (abortListener) {
352
+ try { signal.removeEventListener('abort', abortListener); } catch {}
353
+ }
354
+ }
355
+ if (!line) throw new Error('no native response');
356
+ if (line.startsWith('ERR\t')) throw new Error(line.slice(4));
357
+ if (!line.startsWith('OK\t')) throw new Error(line);
358
+ const f = line.split('\t');
359
+ // OK \t replacements \t readMs \t applyMs \t writeMs \t totalMs \t tier \t hash
360
+ return {
361
+ replacements: Number(f[1]) || 0,
362
+ readMs: Number(f[2]) || 0,
363
+ applyMs: Number(f[3]) || 0,
364
+ writeMs: Number(f[4]) || 0,
365
+ totalMs: Number(f[5]) || 0,
366
+ tier: f[6] || 'exact',
367
+ contentHash: /^[a-f0-9]{64}$/i.test(f[7] || '') ? f[7].toLowerCase() : null,
368
+ roundtripMs: performance.now() - started,
369
+ };
370
+ }
371
+
372
+ async close() {
373
+ if (this.exited) return;
374
+ this.ref();
375
+ try { this.child.stdin.end('QUIT\n'); } catch {}
376
+ await new Promise((resolve) => this.child.once('exit', resolve));
377
+ try { this.rl.close(); } catch {}
378
+ }
379
+ }
380
+
381
+ function getNativePatchServer() {
382
+ const binPath = nativePatchBinPath();
383
+ if (!existsSync(binPath)) {
384
+ throw new Error(`native patch binary not found: ${binPath}`);
385
+ }
386
+ if (!_nativePatchServer || _nativePatchServer.exited || _nativePatchServer.binPath !== binPath) {
387
+ _nativePatchServer = new NativePatchServer(binPath);
388
+ }
389
+ return _nativePatchServer;
390
+ }
391
+
392
+ function getNativeEditServer() {
393
+ // Honor MIXDOG_EDIT_NATIVE_BIN (the same override the edit gating checks) so
394
+ // the gated binary and the spawned server binary cannot diverge.
395
+ const binPath = process.env.MIXDOG_EDIT_NATIVE_BIN || nativePatchBinPath();
396
+ if (!existsSync(binPath)) {
397
+ throw new Error(`native patch binary not found: ${binPath}`);
398
+ }
399
+ if (!_nativeEditServer || _nativeEditServer.exited || _nativeEditServer.binPath !== binPath) {
400
+ _nativeEditServer = new NativePatchServer(binPath);
401
+ }
402
+ return _nativeEditServer;
403
+ }
404
+
405
+ // Invariant-safe char-indexed edit over the persistent server (B2). Shares the
406
+ // NativePatchServer transport but runs on a DEDICATED instance so edit and
407
+ // patch requests never interleave their stdin framing on one stdout stream.
408
+ export async function runServerEdit({ fullPath, oldBuf, newBuf, replaceAll = false, dryRun = false, signal = null }) {
409
+ const server = getNativeEditServer();
410
+ return server.edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
411
+ }
412
+
413
+ function scheduleNativePatchPrewarm() {
414
+ if (!nativePatchPrewarmEnabled() || _nativePatchPrewarmTimer || _nativePatchServer) return;
415
+ _nativePatchPrewarmTimer = setImmediate(() => {
416
+ void (async () => {
417
+ _nativePatchPrewarmTimer = null;
418
+ const started = performance.now();
419
+ try {
420
+ // Ensure the native binary is present (local build or fetched
421
+ // prebuilt) before starting the server. Best-effort: failures
422
+ // surface as a hard error at dispatch (no JS fallback in the
423
+ // native-only path).
424
+ if (!existsSync(nativePatchBinPath())) {
425
+ try { await ensurePatchBinary(getPluginData()); } catch { /* surfaces at dispatch */ }
426
+ }
427
+ await getNativePatchServer().ping();
428
+ if (!nativePatchPersistent() && (_nativePatchServer?.waiters?.length || 0) === 0) {
429
+ _nativePatchServer?.unref();
430
+ }
431
+ if (nativePatchTraceEnabled()) {
432
+ process.stderr.write(`[patch-native-trace] prewarm_ms=${(performance.now() - started).toFixed(3)}\n`);
433
+ }
434
+ } catch (err) {
435
+ if (nativePatchTraceEnabled()) {
436
+ process.stderr.write(`[patch-native-trace] prewarm_failed=${err?.message || String(err)}\n`);
437
+ }
438
+ }
439
+ })();
440
+ });
441
+ if (_nativePatchPrewarmTimer?.unref) _nativePatchPrewarmTimer.unref();
442
+ }
443
+
444
+ scheduleNativePatchPrewarm();
445
+
446
+ function scheduleNativePatchIdleClose() {
447
+ if (nativePatchPersistent() || !_nativePatchServer) return;
448
+ if (process.versions?.bun) {
449
+ const server = _nativePatchServer;
450
+ _nativePatchServer = null;
451
+ void server?.close().catch(() => {});
452
+ return;
453
+ }
454
+ _nativePatchServer.unref();
455
+ }
456
+
457
+ export async function closeNativePatchServerForTests() {
458
+ if (_nativePatchPrewarmTimer) {
459
+ try { clearImmediate(_nativePatchPrewarmTimer); } catch {}
460
+ _nativePatchPrewarmTimer = null;
461
+ }
462
+ const server = _nativePatchServer;
463
+ _nativePatchServer = null;
464
+ const editServer = _nativeEditServer;
465
+ _nativeEditServer = null;
466
+ await server?.close();
467
+ await editServer?.close();
468
+ }
469
+
470
+ // Strip the leading `a/` or `b/` prefix that `diff -u` / git emit by
471
+ // default, plus timestamp suffixes (`\t2024-...`) that some tools append
472
+ // to header lines. parsePatch already splits the name from the header
473
+ // so timestamps land in `oldHeader` / `newHeader`, but be defensive.
474
+ function stripDiffPrefix(name) {
475
+ if (!name) return name;
476
+ if (isAbsolute(name) || /^[A-Za-z]:[\\/]/.test(name)) return name;
477
+ const m = /^[ab]\/(.+)$/.exec(name);
478
+ return m ? m[1] : name;
479
+ }
480
+
481
+ function resolveEntryPath(basePath, rawName) {
482
+ const stripped = stripDiffPrefix(rawName);
483
+ const norm = normalizeInputPath(stripped);
484
+ return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, basePath);
485
+ }
486
+
487
+ // V4A section paths are real repository paths and never carry the unified
488
+ // diff `a/`·`b/` prefix, so resolution must NOT apply stripDiffPrefix — a
489
+ // legitimate top-level `a/` or `b/` directory would otherwise be silently
490
+ // rewritten to its child, reading/writing the wrong file.
491
+ function resolveV4AEntryPath(basePath, rawName) {
492
+ const norm = normalizeInputPath(rawName);
493
+ return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, basePath);
494
+ }
495
+
496
+ function resolveBasePath(cwd, basePath) {
497
+ if (!basePath) return cwd;
498
+ const norm = normalizeInputPath(basePath);
499
+ return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, cwd);
500
+ }
501
+
502
+ // Categorise the per-file entry. A unified diff can describe:
503
+ // - modify : both files named, oldFileName exists on disk
504
+ // - create : oldFileName === /dev/null (or file doesn't exist + hunks start at 0)
505
+ // - delete : newFileName === /dev/null
506
+ function classifyEntry(entry) {
507
+ const oldIsNull = DEV_NULL.test(entry.oldFileName || '');
508
+ const newIsNull = DEV_NULL.test(entry.newFileName || '');
509
+ if (oldIsNull && !newIsNull) return 'create';
510
+ if (!oldIsNull && newIsNull) return 'delete';
511
+ return 'modify';
512
+ }
513
+
514
+ function isPatchErrorText(text) {
515
+ return /^Error:/i.test(String(text ?? '').trimStart());
516
+ }
517
+
518
+ // Count how many source lines a hunk consumes vs produces so we can
519
+ // surface a concise `lines_changed` figure without re-diffing.
520
+ function countHunkChanges(hunks) {
521
+ let added = 0;
522
+ let removed = 0;
523
+ for (const h of hunks || []) {
524
+ for (const line of h.lines || []) {
525
+ if (line.startsWith('+')) added++;
526
+ else if (line.startsWith('-')) removed++;
527
+ }
528
+ }
529
+ return { added, removed };
530
+ }
531
+
532
+ // Header-shape pre-validator (lexical only): a missing header, a
533
+ // `..` segment, or an absolute path that does not resolve inside the
534
+ // basePath returns false. Caller wraps the negative result into a clean
535
+ // throw so unsupported headers surface as a clean error instead of
536
+ // silently degrading to a JS fallback.
537
+ function nativeHeaderSupported(entry, basePath) {
538
+ const kind = classifyEntry(entry);
539
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
540
+ if (!headerName || DEV_NULL.test(headerName)) return false;
541
+ // Reject any `..` segment on EITHER oldFileName or newFileName (every
542
+ // non-/dev/null header) — a modify whose newFileName traverses out of
543
+ // base must still be refused even when the resolved path lands inside.
544
+ for (const which of ['oldFileName', 'newFileName']) {
545
+ const raw = entry[which];
546
+ if (!raw || DEV_NULL.test(raw)) continue;
547
+ const segs = normalizeInputPath(stripDiffPrefix(raw)).split(/[\\/]+/);
548
+ if (segs.some((part) => part === '..')) return false;
549
+ }
550
+ const stripped = stripDiffPrefix(headerName);
551
+ const norm = normalizeInputPath(stripped);
552
+ if (isAbsolute(norm) || /^[A-Za-z]:[\\/]/.test(norm)) {
553
+ if (!basePath) return false;
554
+ const absHeader = pathResolve(norm);
555
+ const absBase = pathResolve(basePath);
556
+ const rel = pathRelative(absBase, absHeader);
557
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return false;
558
+ if (rel.split(/[\\/]+/).some((part) => part === '..')) return false;
559
+ return true;
560
+ }
561
+ return true;
562
+ }
563
+
564
+ // Resolve `absPath` via fs.realpathSync, walking up to the nearest existing
565
+ // ancestor when the leaf does not yet exist (e.g. a create-mode target).
566
+ // Returns the resolved real path, or the lexically-resolved path if no
567
+ // ancestor can be realpath'd.
568
+ function realpathNearestExistingAncestor(absPath) {
569
+ let cur = pathResolve(absPath);
570
+ // eslint-disable-next-line no-constant-condition
571
+ while (true) {
572
+ try {
573
+ return realpathSync(cur);
574
+ } catch {
575
+ const parent = pathDirname(cur);
576
+ if (!parent || parent === cur) return cur;
577
+ cur = parent;
578
+ }
579
+ }
580
+ }
581
+
582
+ // Pre-validator (throws): walks every parsed entry and enforces
583
+ // - native engine is enabled
584
+ // - native binary exists on disk
585
+ // - each header is shape-supported (no `..`, no out-of-base absolute)
586
+ // - realpath of each non-/dev/null header stays inside the real basePath
587
+ // (catches symlink/junction escapes that lexical checks miss)
588
+ // - no duplicate target paths in the patch (case-insensitive on win32)
589
+ // Returns the list of normalized entry rows the dispatcher uses to format
590
+ // output, plus the header-rewrite map for absolute-path normalization.
591
+ async function preValidateNativeBatch(parsed, basePath) {
592
+ if (!nativePatchEnabled()) {
593
+ throw new Error('apply_patch: native engine disabled via MIXDOG_PATCH_NATIVE; set it to "auto" or "1" to apply patches.');
594
+ }
595
+ const binPath = nativePatchBinPath();
596
+ if (!existsSync(binPath)) {
597
+ throw new Error(`apply_patch: native patch binary not found at ${binPath}; build native/mixdog-patch or fetch the prebuilt before invoking apply_patch.`);
598
+ }
599
+ if (!Array.isArray(parsed) || parsed.length === 0) {
600
+ throw new Error('apply_patch: patch contained no file sections');
601
+ }
602
+ await assertPathReachable(basePath);
603
+ const reachabilityPaths = [];
604
+ for (const entry of parsed) {
605
+ for (const which of ['oldFileName', 'newFileName']) {
606
+ const checkName = entry[which];
607
+ if (!checkName || DEV_NULL.test(checkName)) continue;
608
+ reachabilityPaths.push(resolveEntryPath(basePath, checkName));
609
+ }
610
+ }
611
+ await assertPathsReachable(reachabilityPaths);
612
+ let realBase;
613
+ try {
614
+ realBase = realpathSync(pathResolve(basePath));
615
+ } catch (err) {
616
+ throw new Error(`apply_patch: base_path unreadable (${err?.code || err?.message || String(err)}): ${basePath}`);
617
+ }
618
+ const entries = [];
619
+ const seenPaths = new Set();
620
+ const headerRewrites = [];
621
+ for (const entry of parsed) {
622
+ const kind = classifyEntry(entry);
623
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
624
+ if (!nativeHeaderSupported(entry, basePath)) {
625
+ const display = headerName ? normalizeOutputPath(stripDiffPrefix(headerName)) : '(unknown)';
626
+ throw new Error(`apply_patch: header ${display} is unsupported (path escapes base_path or contains \`..\`).`);
627
+ }
628
+ if (kind !== 'delete' && !(entry.hunks?.length > 0)) {
629
+ const display = headerName ? normalizeOutputPath(stripDiffPrefix(headerName)) : '(unknown)';
630
+ throw new Error(`apply_patch: entry ${display} has no hunks — patch header malformed (use \`@@ -A,B +C,D @@\` per hunk).`);
631
+ }
632
+ // Realpath each non-/dev/null header; nearest-existing-ancestor handles
633
+ // create-mode leaves that do not yet exist. A symlink/junction whose
634
+ // target escapes basePath fails here even when the lexical check above
635
+ // looked safe.
636
+ for (const which of ['oldFileName', 'newFileName']) {
637
+ const checkName = entry[which];
638
+ if (!checkName || DEV_NULL.test(checkName)) continue;
639
+ const checkFull = resolveEntryPath(basePath, checkName);
640
+ const checkReal = realpathNearestExistingAncestor(checkFull);
641
+ const checkRel = pathRelative(realBase, checkReal);
642
+ if (checkRel.split(/[\\/]+/).some((part) => part === '..') || isAbsolute(checkRel)) {
643
+ const display = normalizeOutputPath(stripDiffPrefix(checkName));
644
+ throw new Error(`apply_patch: ${display} resolves outside base_path via symlink/junction; refusing to apply.`);
645
+ }
646
+ }
647
+ const fullPath = resolveEntryPath(basePath, headerName);
648
+ const pathKey = process.platform === 'win32' ? fullPath.toLowerCase() : fullPath;
649
+ if (seenPaths.has(pathKey)) {
650
+ const display = normalizeOutputPath(stripDiffPrefix(headerName));
651
+ throw new Error(`apply_patch: duplicate target ${display} — patch lists the same path twice.`);
652
+ }
653
+ seenPaths.add(pathKey);
654
+ const displayPath = normalizeOutputPath(stripDiffPrefix(headerName));
655
+ const { added, removed } = countHunkChanges(entry.hunks);
656
+ entries.push({
657
+ kind,
658
+ fullPath,
659
+ displayPath,
660
+ hunks: entry.hunks?.length || 0,
661
+ linesChanged: added + removed,
662
+ });
663
+ // Absolute-form headers must be rewritten to repo-relative before the
664
+ // native server, which joins headers to basePath, sees them.
665
+ for (const which of ['oldFileName', 'newFileName']) {
666
+ const raw = entry[which];
667
+ if (!raw || DEV_NULL.test(raw)) continue;
668
+ const stripped = stripDiffPrefix(raw);
669
+ const norm = normalizeInputPath(stripped);
670
+ if (!isAbsolute(norm) && !/^[A-Za-z]:[\\/]/.test(norm)) continue;
671
+ const rel = pathRelative(pathResolve(basePath), pathResolve(norm)).replace(/\\/g, '/');
672
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
673
+ const display = normalizeOutputPath(stripDiffPrefix(raw));
674
+ throw new Error(`apply_patch: absolute header ${display} does not resolve inside base_path.`);
675
+ }
676
+ headerRewrites.push({ from: raw, to: rel });
677
+ }
678
+ }
679
+ return { entries, headerRewrites };
680
+ }
681
+
682
+ // Rewrite ONLY the file-section header lines (`--- old`/`+++ new`) that
683
+ // precede each hunk so the native server, which joins headers to
684
+ // basePath, never sees an absolute header. A hunk DELETION line is `-`
685
+ // + content, so a deleted line whose body text is `-- C:/...` renders as
686
+ // `--- C:/...`; track hunk-body state by consuming each `@@ -a,b +c,d @@`
687
+ // header's declared line counts so only lines outside any hunk body are
688
+ // eligible for rewrite.
689
+ function rewriteHeaderPaths(patchStr, headerRewrites) {
690
+ if (!headerRewrites || headerRewrites.length === 0) return patchStr;
691
+ const lines = patchStr.split('\n');
692
+ let i = 0;
693
+ while (i < lines.length) {
694
+ const line = lines[i];
695
+ if (line.startsWith('@@ ')) {
696
+ const m = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
697
+ let oldRem = m && m[1] !== undefined ? Number(m[1]) : 1;
698
+ let newRem = m && m[2] !== undefined ? Number(m[2]) : 1;
699
+ i++;
700
+ while (i < lines.length && (oldRem > 0 || newRem > 0)) {
701
+ const body = lines[i];
702
+ const c = body.charAt(0);
703
+ if (c === ' ') { oldRem--; newRem--; }
704
+ else if (c === '-') { oldRem--; }
705
+ else if (c === '+') { newRem--; }
706
+ else if (c === '\\') { /* "" marker */ }
707
+ else break;
708
+ i++;
709
+ }
710
+ continue;
711
+ }
712
+ if (line.startsWith('--- ') || line.startsWith('+++ ')) {
713
+ const prefix = line.slice(0, 4);
714
+ const rest = line.slice(4);
715
+ const tabIdx = rest.indexOf('\t');
716
+ const pathPart = tabIdx === -1 ? rest : rest.slice(0, tabIdx);
717
+ const suffix = tabIdx === -1 ? '' : rest.slice(tabIdx);
718
+ for (const { from, to } of headerRewrites) {
719
+ if (pathPart === from) {
720
+ lines[i] = `${prefix}${to}${suffix}`;
721
+ break;
722
+ }
723
+ }
724
+ }
725
+ i++;
726
+ }
727
+ return lines.join('\n');
728
+ }
729
+
730
+ function collectUnifiedOldLines(hunk) {
731
+ const oldLines = [];
732
+ // Track whether the immediately preceding hunk line contributed an OLD
733
+ // (context/delete) line, so a following "" marker
734
+ // can flip the trailing-newline flag on the right entry. hasNewline is EXTRA
735
+ // metadata: the push condition stays IDENTICAL to before so old-line offsets
736
+ // and the change-band coordinates are unaffected.
737
+ let lastWasOld = false;
738
+ for (const raw of hunk?.lines || []) {
739
+ if (typeof raw !== 'string' || raw.length === 0) continue;
740
+ const tag = raw[0];
741
+ if (tag === '-' || tag === ' ') {
742
+ oldLines.push({ tag, line: raw.slice(1), hasNewline: true });
743
+ lastWasOld = true;
744
+ continue;
745
+ }
746
+ if (tag === '\\') {
747
+ // Unified "": the immediately preceding line
748
+ // has no trailing newline. Mirror native (main.rs:1500-1526), which flips
749
+ // the has_newline flag on the preceding line. Only apply to OLD lines —
750
+ // an add-line marker does not affect the context/delete old-line list.
751
+ if (lastWasOld && oldLines.length > 0) oldLines[oldLines.length - 1].hasNewline = false;
752
+ lastWasOld = false;
753
+ continue;
754
+ }
755
+ lastWasOld = false;
756
+ }
757
+ return oldLines;
758
+ }
759
+
760
+ // Ordered op sequence for a hunk (Context/Delete/Add), in source order. Mirrors
761
+ // native parts.ops: needed for the change-band computation because Add lines do
762
+ // not live in the old-line list yet still bound the interior context region.
763
+ // The push condition for context/delete is kept IDENTICAL to
764
+ // collectUnifiedOldLines so old-line offsets line up across both views.
765
+ function collectUnifiedOps(hunk) {
766
+ const ops = [];
767
+ for (const raw of hunk?.lines || []) {
768
+ if (typeof raw !== 'string' || raw.length === 0) continue;
769
+ const tag = raw[0];
770
+ if (tag === ' ') ops.push('context');
771
+ else if (tag === '-') ops.push('delete');
772
+ else if (tag === '+') ops.push('add');
773
+ }
774
+ return ops;
775
+ }
776
+
777
+ // Mirror native evaluate_fuzzy_candidate change-band (main.rs:1744-1766): map
778
+ // the ordered op sequence into a doubled+shifted old-index coordinate space.
779
+ // old_cursor counts consumed OLD lines; Delete at cursor o -> (o+1)*2, Add at
780
+ // cursor k -> k*2+1 (strictly between neighbouring old lines). first/last span
781
+ // over Adds AND Deletes. {first:null,last:null} means no changes at all.
782
+ function computeUnifiedChangeBand(ops) {
783
+ let first = null;
784
+ let last = null;
785
+ let oldCursor = 0;
786
+ for (const op of ops) {
787
+ if (op === 'context') {
788
+ oldCursor += 1;
789
+ } else if (op === 'delete') {
790
+ const pos = (oldCursor + 1) * 2;
791
+ first = first === null ? pos : Math.min(first, pos);
792
+ last = last === null ? pos : Math.max(last, pos);
793
+ oldCursor += 1;
794
+ } else {
795
+ const pos = oldCursor * 2 + 1;
796
+ first = first === null ? pos : Math.min(first, pos);
797
+ last = last === null ? pos : Math.max(last, pos);
798
+ }
799
+ }
800
+ return { first, last };
801
+ }
802
+
803
+ function firstMeaningfulUnifiedHunkLine(hunk) {
804
+ for (const { line } of collectUnifiedOldLines(hunk)) {
805
+ if (line.trim()) return { line, preferredLine: Math.max(0, (Number(hunk?.oldStart) || 1) - 1) };
806
+ }
807
+ return null;
808
+ }
809
+
810
+ // First FAILING old line within a hunk — not its first line. Anchor at the
811
+ // deepest-matching prefix (declared oldStart first, then any position where
812
+ // the first old line matches) and report the line where the match breaks.
813
+ // Without this the rejection message shows the hunk's FIRST context line,
814
+ // which often matches perfectly, next to a "nearest" source line identical
815
+ // to it — telling the caller nothing about the actual mismatch.
816
+ function firstFailingUnifiedHunkLineDetail(sourceLines, hunk) {
817
+ const oldLines = collectUnifiedOldLines(hunk);
818
+ if (!oldLines.length) return null;
819
+ const lineEq = (actual, expected) => {
820
+ const ab = toLineBytes(actual);
821
+ const eb = toLineBytes(expected);
822
+ return ab.equals(eb) || byteTrimPatchWhitespace(ab).equals(byteTrimPatchWhitespace(eb));
823
+ };
824
+ const prefixDepth = (start) => {
825
+ let d = 0;
826
+ while (d < oldLines.length && start + d < sourceLines.length && lineEq(sourceLines[start + d], oldLines[d].line)) d += 1;
827
+ return d;
828
+ };
829
+ const declared = Math.max(0, (Number(hunk?.oldStart) || 1) - 1);
830
+ const candidates = [];
831
+ if (declared < sourceLines.length) candidates.push(declared);
832
+ for (let i = 0; i < sourceLines.length && candidates.length < 8; i++) {
833
+ if (i !== declared && lineEq(sourceLines[i], oldLines[0].line)) candidates.push(i);
834
+ }
835
+ let best = null;
836
+ for (const start of candidates) {
837
+ const depth = prefixDepth(start);
838
+ if (depth >= oldLines.length) continue;
839
+ if (!best || depth > best.depth) best = { start, depth };
840
+ }
841
+ // depth 0 → the hunk's first line itself never anchored; the existing
842
+ // first-meaningful-line message is already the right diagnostic there.
843
+ if (!best || best.depth === 0) return null;
844
+ return {
845
+ line: oldLines[best.depth].line,
846
+ preferredLine: best.start + best.depth,
847
+ };
848
+ }
849
+
850
+ function firstMeaningfulUnifiedEntryLine(entry) {
851
+ const hunks = Array.isArray(entry?.hunks) ? entry.hunks : [];
852
+ for (const hunk of hunks) {
853
+ const expected = firstMeaningfulUnifiedHunkLine(hunk);
854
+ if (expected) return expected;
855
+ }
856
+ return null;
857
+ }
858
+
859
+ // Mirror native trim_patch_ws (main.rs:1877-1899): strip ONLY space and tab
860
+ // (b' '|b'\t', not the full \s class) from BOTH ends of the line.
861
+ function trimPatchWhitespace(text) {
862
+ return String(text ?? '').replace(/^[ \t]+/u, '').replace(/[ \t]+$/u, '');
863
+ }
864
+
865
+ // --- Byte-level diagnostic-matcher substrate (diagnostics ONLY) ---------------
866
+ // The native engine compares RAW BYTES (main.rs:1867-1875 exact, 1891-1899 ws,
867
+ // 1936-1944 normalize). The JS diagnostic matcher mirrors that on Buffer line
868
+ // slices so invalid-UTF-8 source bytes are NOT pre-collapsed to U+FFFD. These
869
+ // helpers feed unifiedOldLinesMatchAt / findFirstFailingUnifiedHunk and nothing
870
+ // on the V4A conversion path.
871
+
872
+ // Coerce a matcher line value to bytes. Buffer (byte source view OR a raw-byte
873
+ // expected line injected by a test) is used as-is; a string (parsed-patch
874
+ // expected body, already valid UTF-8 — or a legacy/test plain source array) is
875
+ // encoded as UTF-8. Mirrors the fact that native holds both sides as bytes.
876
+ function toLineBytes(v) {
877
+ return Buffer.isBuffer(v) ? v : Buffer.from(String(v ?? ''), 'utf8');
878
+ }
879
+
880
+ // Byte version of trim_patch_ws (main.rs:1891-1899): strip ONLY 0x20/0x09 from
881
+ // BOTH ends, returning a (zero-copy) subview.
882
+ function byteTrimPatchWhitespace(buf) {
883
+ let start = 0;
884
+ let end = buf.length;
885
+ while (start < end && (buf[start] === 0x20 || buf[start] === 0x09)) start++;
886
+ while (end > start && (buf[end - 1] === 0x20 || buf[end - 1] === 0x09)) end--;
887
+ return buf.subarray(start, end);
888
+ }
889
+
890
+ // Reused fatal UTF-8 decoder: mirror native's `from_utf8` validity gate
891
+ // (main.rs:1936-1944). Returns the decoded string when the bytes are valid
892
+ // UTF-8, else null (the normalize tier is then refused for that line).
893
+ const __utf8FatalDecoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
894
+ function decodeValidUtf8OrNull(buf) {
895
+ try {
896
+ return __utf8FatalDecoder.decode(buf);
897
+ } catch {
898
+ return null;
899
+ }
900
+ }
901
+
902
+ function unifiedOldLinesMatchAt(sourceLines, oldLines, startIdx, fuzz, band) {
903
+ if (startIdx < 0 || startIdx + oldLines.length > sourceLines.length) return null;
904
+ let fuzzUsed = 0;
905
+ let normCount = 0;
906
+ // A source line has a trailing newline EXCEPT the final line when the file
907
+ // body did not end in '\n' (metadata stashed on the array by
908
+ // splitTextLinesForPatch). Default true when metadata is absent (callers that
909
+ // pass a plain literal array, e.g. tests/legacy) so behaviour is unchanged
910
+ // unless they opt in by setting hasFinalNewline.
911
+ const srcFinalNewline = sourceLines.hasFinalNewline !== false;
912
+ const lastSrcIdx = sourceLines.length - 1;
913
+ for (let offset = 0; offset < oldLines.length; offset++) {
914
+ const expected = oldLines[offset];
915
+ const actualIdx = startIdx + offset;
916
+ // Byte substrate (diagnostics-only): compare RAW BYTES like native instead
917
+ // of JS strings, so invalid-UTF-8 source bytes are not pre-collapsed to
918
+ // U+FFFD. `actualBytes` is a per-line Buffer slice when sourceLines is the
919
+ // byte view (formatNativeFailureContext) and an on-the-fly UTF-8 encode for
920
+ // a plain-string/legacy/test source array; `expectedBytes` is the UTF-8
921
+ // encoding of the parsed-patch old line (or a raw Buffer when a test injects
922
+ // invalid bytes directly).
923
+ const actualBytes = toLineBytes(sourceLines[actualIdx]);
924
+ const expectedBytes = toLineBytes(expected.line);
925
+ // Per-line newline guard (mirror native exact-newline guard main.rs:1867-1875
926
+ // and ws/normalize guards 1891-1899/1928-1947): a tier matches ONLY when the
927
+ // expected and actual trailing-newline state agree. expected.hasNewline is
928
+ // the unified old line's flag (default true, cleared by a "\ No newline"
929
+ // marker); the source line lacks a newline only when it is the final line of
930
+ // a no-trailing-newline file. When they differ, NO tier accepts this line —
931
+ // for a delete line this then falls through to reject (mirror native
932
+ // 1832-1835).
933
+ const expectedNL = expected.hasNewline !== false;
934
+ const actualNL = !(actualIdx === lastSrcIdx && !srcFinalNewline);
935
+ const newlineOk = expectedNL === actualNL;
936
+ // Exact tier (main.rs:1867-1875): raw byte equality.
937
+ if (newlineOk && actualBytes.equals(expectedBytes)) continue;
938
+ // Whitespace tier: mirror native (gate main.rs:1787-1790) which accepts a
939
+ // Context OR Delete line that matches after trimming space/tab (0x20/0x09)
940
+ // from BOTH ends (byte trim, main.rs:1891-1899). Costs no fuzz and does NOT
941
+ // increment normCount.
942
+ if (newlineOk && fuzz > 0 && (expected.tag === ' ' || expected.tag === '-') && byteTrimPatchWhitespace(actualBytes).equals(byteTrimPatchWhitespace(expectedBytes))) continue;
943
+ // Unicode-normalization tier: mirror the native engine, which accepts a
944
+ // context OR delete line that matches only after typographic normalization
945
+ // (dashes/quotes/NBSP) at zero fuzz cost (native gate: Context|Delete). The
946
+ // count of such normalization-only matches is tracked so the candidate
947
+ // selector can prefer a block that anchored without normalization. Without
948
+ // this the diagnostic matcher mislabels which hunk first failed when the
949
+ // source carries exotic code-points.
950
+ //
951
+ // UTF-8 validity guard (exact mirror of native main.rs:1936-1944, which
952
+ // normalizes ONLY when BOTH bodies are valid UTF-8): decode each side with a
953
+ // fatal TextDecoder; if EITHER side is invalid UTF-8 the normalize tier is
954
+ // refused (matching native's `from_utf8` gate). This replaces the old
955
+ // string-level U+FFFD heuristic — a VALID literal U+FFFD present in both
956
+ // sides now normalize-matches, exactly as native does.
957
+ if (
958
+ newlineOk
959
+ && fuzz > 0
960
+ && (expected.tag === ' ' || expected.tag === '-')
961
+ ) {
962
+ const actualStr = decodeValidUtf8OrNull(actualBytes);
963
+ const expectedStr = actualStr === null ? null : decodeValidUtf8OrNull(expectedBytes);
964
+ if (
965
+ actualStr !== null
966
+ && expectedStr !== null
967
+ && normalizeTypographic(actualStr) === normalizeTypographic(expectedStr)
968
+ ) {
969
+ normCount++;
970
+ continue;
971
+ }
972
+ }
973
+ if (fuzz > 0 && expected.tag === ' ') {
974
+ // Mirror native (main.rs:1808-1830): content drift on a context line is
975
+ // only fuzz-consumable when the line is OUTER context (before the first
976
+ // change or after the last). Interior context (strictly inside the change
977
+ // band) must match exactly/ws/normalize — content drift there means the
978
+ // hunk is binding to a different block, so reject. ctx_pos maps the old
979
+ // line at this offset into the band coordinate space: (offset + 1) * 2.
980
+ const ctxPos = (offset + 1) * 2;
981
+ const isOuter = (!band || band.first === null || band.last === null)
982
+ ? true
983
+ : (ctxPos < band.first || ctxPos > band.last);
984
+ if (!isOuter) return null;
985
+ fuzzUsed++;
986
+ if (fuzzUsed <= fuzz) continue;
987
+ }
988
+ return null;
989
+ }
990
+ return { fuzzUsed, normCount };
991
+ }
992
+
993
+ function findUnifiedHunkMatch(sourceLines, hunk, minStartIdx, fuzz) {
994
+ const oldLines = collectUnifiedOldLines(hunk);
995
+ const band = computeUnifiedChangeBand(collectUnifiedOps(hunk));
996
+ const oldStart = Math.max(0, (Number(hunk?.oldStart) || 1) - 1);
997
+ if (oldLines.length === 0) {
998
+ const insertIdx = Math.max(0, Number(hunk?.oldStart) || 0);
999
+ return insertIdx >= minStartIdx && insertIdx <= sourceLines.length ? { start: insertIdx, end: insertIdx } : null;
1000
+ }
1001
+ if (oldStart >= minStartIdx && unifiedOldLinesMatchAt(sourceLines, oldLines, oldStart, 0, band) !== null) {
1002
+ return { start: oldStart, end: oldStart + oldLines.length };
1003
+ }
1004
+ if (fuzz <= 0) return null;
1005
+ let best = null;
1006
+ for (let start = minStartIdx; start <= sourceLines.length - oldLines.length; start++) {
1007
+ const matched = unifiedOldLinesMatchAt(sourceLines, oldLines, start, fuzz, band);
1008
+ if (matched === null) continue;
1009
+ const { fuzzUsed, normCount } = matched;
1010
+ const distance = Math.abs(start - oldStart);
1011
+ // Ordering mirrors native: lower fuzz, THEN fewer normalization-only
1012
+ // matches, THEN smaller distance. A block that anchored without
1013
+ // normalization always beats a nearer one that needed it.
1014
+ if (
1015
+ !best ||
1016
+ fuzzUsed < best.fuzzUsed ||
1017
+ (fuzzUsed === best.fuzzUsed && normCount < best.normCount) ||
1018
+ (fuzzUsed === best.fuzzUsed && normCount === best.normCount && distance < best.distance)
1019
+ ) {
1020
+ best = { start, distance, fuzzUsed, normCount };
1021
+ }
1022
+ }
1023
+ return best ? { start: best.start, end: best.start + oldLines.length } : null;
1024
+ }
1025
+
1026
+ function findFirstFailingUnifiedHunk(entry, sourceLines, fuzz) {
1027
+ const hunks = Array.isArray(entry?.hunks) ? entry.hunks : [];
1028
+ let minStartIdx = 0;
1029
+ for (const hunk of hunks) {
1030
+ const match = findUnifiedHunkMatch(sourceLines, hunk, minStartIdx, fuzz);
1031
+ if (!match) return hunk;
1032
+ minStartIdx = Math.max(minStartIdx, match.end);
1033
+ }
1034
+ return null;
1035
+ }
1036
+
1037
+ function nativeFailurePathCandidates(parsed) {
1038
+ const candidates = new Set();
1039
+ for (const entry of Array.isArray(parsed) ? parsed : []) {
1040
+ const kind = classifyEntry(entry);
1041
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
1042
+ if (!headerName) continue;
1043
+ const stripped = stripDiffPrefix(headerName);
1044
+ const display = normalizeOutputPath(stripped);
1045
+ candidates.add(headerName);
1046
+ candidates.add(stripped);
1047
+ candidates.add(display);
1048
+ if (display) {
1049
+ candidates.add(`a/${display}`);
1050
+ candidates.add(`b/${display}`);
1051
+ }
1052
+ }
1053
+ return [...candidates].filter(Boolean).sort((a, b) => b.length - a.length);
1054
+ }
1055
+
1056
+ function extractNativeFailurePath(message, parsed) {
1057
+ const text = String(message || '').trim();
1058
+ if (!text) return '';
1059
+ const candidates = nativeFailurePathCandidates(parsed);
1060
+ for (const candidate of candidates) {
1061
+ if (text.startsWith(`${candidate}:`)) return candidate;
1062
+ }
1063
+ const hunkMatch = /(?:^|\b)hunk rejected in (.+?)(?: \(|$)/i.exec(text);
1064
+ if (hunkMatch?.[1]) return hunkMatch[1].trim();
1065
+ for (const candidate of candidates) {
1066
+ if (text.includes(candidate)) return candidate;
1067
+ }
1068
+ return '';
1069
+ }
1070
+
1071
+ function nativeFailureMatchesEntry(entry, failedPath) {
1072
+ if (!failedPath) return true;
1073
+ const kind = classifyEntry(entry);
1074
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
1075
+ if (!headerName) return false;
1076
+ const failed = normalizeOutputPath(stripDiffPrefix(failedPath));
1077
+ const display = normalizeOutputPath(stripDiffPrefix(headerName));
1078
+ if (!failed || !display) return false;
1079
+ // Exact match: single-file / relative-header case.
1080
+ if (failed === display) return true;
1081
+ // Path-segment-aware containment, both directions:
1082
+ // - display.endsWith('/'+failed): an accepted ABSOLUTE header is rewritten
1083
+ // to a repo-relative path before native dispatch, so the native failure
1084
+ // reports the RELATIVE path while this parsed entry still carries its
1085
+ // ORIGINAL absolute (or longer) path — `.../src/b.js` matches `src/b.js`.
1086
+ // - failed.endsWith('/'+display): the inverse (native path longer than the
1087
+ // parsed header).
1088
+ // Anchoring on a leading `/` keeps this segment-boundary aware, so
1089
+ // `.../notsrc/app.js` never matches `src/app.js` via raw substring.
1090
+ return display.endsWith(`/${failed}`) || failed.endsWith(`/${display}`);
1091
+ }
1092
+
1093
+ function formatNativeFailureContext(parsed, basePath, failedPath = '', options = {}) {
1094
+ const entries = Array.isArray(parsed) ? parsed : [];
1095
+ const entry = entries.find((candidate) => classifyEntry(candidate) !== 'create' && nativeFailureMatchesEntry(candidate, failedPath))
1096
+ || entries.find((candidate) => classifyEntry(candidate) !== 'create');
1097
+ const headerName = entry?.oldFileName;
1098
+ const displayPath = headerName ? normalizeOutputPath(stripDiffPrefix(headerName)) : '';
1099
+ const fuzz = Number.isFinite(options?.fuzz) && options.fuzz > 0 ? Math.floor(options.fuzz) : 0;
1100
+ // Two SEPARATE views of the same source file (this is the ONLY shared-source
1101
+ // site): `sourceLines` stays a decoded UTF-8 string[] for the downstream hint
1102
+ // heuristics (nearestPatchLineHint, .trim(), normalizeTypographic below) which
1103
+ // are inherently string-based; `sourceByteLines` is an independent raw-byte
1104
+ // view (per-line Buffer slices) for the byte-parity diagnostic matcher so
1105
+ // invalid-UTF-8 bytes are not pre-collapsed to U+FFFD. The V4A conversion path
1106
+ // (splitTextLinesForPatch at ~1644/~1660 feeding findLineSequence) is NOT
1107
+ // touched — it keeps reading utf8 strings.
1108
+ let sourceLines = null;
1109
+ let sourceByteLines = null;
1110
+ try {
1111
+ const fullPath = resolveEntryPath(basePath, entry.oldFileName);
1112
+ const raw = readFileSync(fullPath); // Buffer — no 'utf8' decode
1113
+ sourceByteLines = splitBufferLinesForPatch(raw);
1114
+ sourceLines = splitTextLinesForPatch(raw.toString('utf8'));
1115
+ } catch {}
1116
+ const failingHunk = sourceByteLines ? findFirstFailingUnifiedHunk(entry, sourceByteLines, fuzz) : null;
1117
+ const failingDetail = (failingHunk && sourceByteLines)
1118
+ ? firstFailingUnifiedHunkLineDetail(sourceByteLines, failingHunk)
1119
+ : null;
1120
+ const expected = failingDetail || firstMeaningfulUnifiedHunkLine(failingHunk) || firstMeaningfulUnifiedEntryLine(entry);
1121
+ if (!entry || !expected?.line) return '';
1122
+ const expectedText = JSON.stringify(compactPatchPreviewLine(expected.line));
1123
+ let nearest = '';
1124
+ let normalizeHint = '';
1125
+ if (sourceLines) {
1126
+ nearest = nearestPatchLineHint(sourceLines, expected.line, expected.preferredLine);
1127
+ // Typographic-mismatch hint: if the expected context line matches a source
1128
+ // line ONLY after Unicode normalization, the source likely carries
1129
+ // typographic dashes/quotes/NBSP that an ASCII patch can't match exactly.
1130
+ const wantNorm = normalizeTypographic(expected.line);
1131
+ if (wantNorm) {
1132
+ for (let i = 0; i < sourceLines.length; i++) {
1133
+ if (sourceLines[i] === expected.line) break; // exact match exists; not a normalization issue
1134
+ // normalizeTypographic also .trim()s, so a pure trailing/leading
1135
+ // whitespace drift would otherwise fire this typographic hint. Require
1136
+ // a genuine code-point difference: the lines must still differ after a
1137
+ // plain trim, yet become equal after typographic normalization.
1138
+ if (
1139
+ sourceLines[i].trim() !== expected.line.trim()
1140
+ && normalizeTypographic(sourceLines[i]) === wantNorm
1141
+ ) {
1142
+ normalizeHint = `context matches after Unicode normalization at line ${i + 1} — source may contain typographic dashes/quotes/NBSP`;
1143
+ break;
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ return ` expected first old/context line${displayPath ? ` in ${displayPath}` : ''}: ${expectedText}${nearest ? `; ${nearest}` : ''}${normalizeHint ? `; ${normalizeHint}` : ''}; use exact current lines, no stubs.`;
1149
+ }
1150
+
1151
+ // Dispatch the (already validated, header-rewritten) patch to the native
1152
+ // engine. Throws on any native error; on success returns the formatted
1153
+ // human-readable response string. Never silently falls back to JS — the
1154
+ // caller MUST surface throws as `Error: ...` strings.
1155
+ async function dispatchNativePatch({ entries, basePath, nativePatchStr, fuzz, rejectPartial, dryRun, readStateScope, signal, parsed }) {
1156
+ const nativeStart = performance.now();
1157
+ let stats;
1158
+ try {
1159
+ stats = await getNativePatchServer().apply(basePath, nativePatchStr, { fuzz, rejectPartial, dryRun, signal });
1160
+ } catch (err) {
1161
+ scheduleNativePatchIdleClose();
1162
+ const msg = err?.message || String(err);
1163
+ const failedPath = extractNativeFailurePath(msg, parsed);
1164
+ return `Error: native patch failed — ${msg}${formatNativeFailureContext(parsed, basePath, failedPath, { fuzz })}`;
1165
+ }
1166
+ const afterInvalidateStart = performance.now();
1167
+ // Only invalidate / snapshot entries that actually landed. In isolation
1168
+ // mode (OK_PARTIAL) skipped entries still have their original disk state
1169
+ // and must not be re-snapshotted.
1170
+ const failedDisplaySet = new Set();
1171
+ for (const f of stats.failures || []) {
1172
+ if (!f?.path) continue;
1173
+ failedDisplaySet.add(normalizeOutputPath(f.path));
1174
+ failedDisplaySet.add(normalizeOutputPath(stripDiffPrefix(f.path)));
1175
+ }
1176
+ const writtenEntries = entries.filter((entry) => !failedDisplaySet.has(entry.displayPath));
1177
+ const fullPaths = writtenEntries.map((entry) => entry.fullPath);
1178
+ if (!dryRun) invalidateBuiltinResultCache(fullPaths);
1179
+ const afterInvalidate = performance.now();
1180
+ if (!dryRun) markCodeGraphDirtyPaths(fullPaths);
1181
+ const afterDirty = performance.now();
1182
+ if (!dryRun) {
1183
+ for (let i = 0; i < writtenEntries.length; i++) {
1184
+ const entry = writtenEntries[i];
1185
+ if (entry.kind === 'delete') {
1186
+ clearReadSnapshotForPath(entry.fullPath, readStateScope);
1187
+ } else {
1188
+ const snapshotMeta = {
1189
+ source: 'apply_patch_native',
1190
+ isPartialView: false,
1191
+ };
1192
+ const contentHash = stats.contentHashes?.[i] || null;
1193
+ if (contentHash) snapshotMeta.contentHash = contentHash;
1194
+ recordReadSnapshotForPath(entry.fullPath, readStateScope, snapshotMeta);
1195
+ }
1196
+ }
1197
+ }
1198
+ const afterSnapshot = performance.now();
1199
+ ioTrace('apply_patch_native', {
1200
+ files: writtenEntries.length,
1201
+ dryRun,
1202
+ partial: stats.partial,
1203
+ failed: stats.failures.length,
1204
+ roundtripMs: Number(stats.roundtripMs.toFixed(3)),
1205
+ rustTotalMs: Number(stats.totalMs.toFixed(3)),
1206
+ invalidateMs: Number((afterInvalidate - afterInvalidateStart).toFixed(3)),
1207
+ dirtyMs: Number((afterDirty - afterInvalidate).toFixed(3)),
1208
+ snapshotMs: Number((afterSnapshot - afterDirty).toFixed(3)),
1209
+ contentHashes: (stats.contentHashes || []).filter(Boolean).length,
1210
+ });
1211
+ if (nativePatchTraceEnabled()) {
1212
+ process.stderr.write(
1213
+ `[patch-native-trace] files=${writtenEntries.length} partial=${stats.partial ? 1 : 0} failed=${stats.failures.length} roundtrip_ms=${stats.roundtripMs.toFixed(3)} rust_total_ms=${stats.totalMs.toFixed(3)} rust_hash_ms=${stats.hashMs.toFixed(3)} invalidate_ms=${(afterInvalidate - afterInvalidateStart).toFixed(3)} dirty_ms=${(afterDirty - afterInvalidate).toFixed(3)} snapshot_ms=${(afterSnapshot - afterDirty).toFixed(3)} total_js_ms=${(afterSnapshot - nativeStart).toFixed(3)} content_hashes=${(stats.contentHashes || []).filter(Boolean).length}\n`
1214
+ );
1215
+ }
1216
+ if (patchTraceEnabled()) {
1217
+ process.stderr.write(`[patch-native] applied files=${writtenEntries.length} partial=${stats.partial ? 1 : 0} ms=${stats.totalMs.toFixed(3)}\n`);
1218
+ }
1219
+ scheduleNativePatchIdleClose();
1220
+ const verb = dryRun ? 'checked' : 'applied';
1221
+ const summary = stats.partial
1222
+ ? `Error: patch partially ${dryRun ? 'checked' : 'applied'} (${verb} ${writtenEntries.length}, ${stats.failures.length} skipped) (native)`
1223
+ : `${verb} ${writtenEntries.length} (native)${dryRun ? ' dry-run' : ''}`;
1224
+ const lines = [summary];
1225
+ for (const entry of writtenEntries) {
1226
+ lines.push(` OK ${entry.kind} ${entry.displayPath} ±${entry.linesChanged}L/${entry.hunks}h`);
1227
+ }
1228
+ for (const f of stats.failures || []) {
1229
+ lines.push(` SKIP ${f.path || '(unknown)'} — ${f.reason}${formatNativeFailureContext(parsed, basePath, f.path, { fuzz })}`);
1230
+ }
1231
+ return lines.join('\n');
1232
+ }
1233
+
1234
+ // Strip BOM + normalize CRLF→LF only. Idempotent and structural — no
1235
+ // hunk metadata is rewritten.
1236
+ function prepareInput(patchStr) {
1237
+ return String(patchStr).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
1238
+ }
1239
+
1240
+ function isCodexApplyPatchEnvelope(patchStr) {
1241
+ const text = prepareInput(patchStr).trimStart();
1242
+ return text.startsWith('*** Begin Patch')
1243
+ || text.startsWith('*** Add File:')
1244
+ || text.startsWith('*** Update File:')
1245
+ || text.startsWith('*** Delete File:');
1246
+ }
1247
+
1248
+ function isV4APatchInput(patchStr, format) {
1249
+ return String(format || '').toLowerCase() === 'v4a'
1250
+ || isCodexApplyPatchEnvelope(patchStr);
1251
+ }
1252
+
1253
+ const UNIFIED_HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/;
1254
+ const UNIFIED_HUNK_HEADER_CAPTURE_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$/;
1255
+
1256
+ function hasUnifiedBareV4AHunk(patchStr) {
1257
+ const text = prepareInput(patchStr);
1258
+ if (!/^--- /m.test(text) || !/^\+\+\+ /m.test(text)) return false;
1259
+ return text.split('\n').some((line) => line.startsWith('@@') && !UNIFIED_HUNK_HEADER_RE.test(line));
1260
+ }
1261
+
1262
+ function isUnifiedHunkCountError(err) {
1263
+ const message = String(err?.message || err || '');
1264
+ return /Hunk at line .*more lines than expected|Hunk at line .*less lines than expected|expected \d+ old lines|line count did not match/i.test(message);
1265
+ }
1266
+
1267
+ function canFallbackCountedUnified(patchStr, requestedFormat, err) {
1268
+ if (requestedFormat === 'unified') return false;
1269
+ if (isV4APatchInput(patchStr, requestedFormat)) return false;
1270
+ const text = prepareInput(patchStr);
1271
+ return /^--- /m.test(text)
1272
+ && /^\+\+\+ /m.test(text)
1273
+ && UNIFIED_HUNK_HEADER_RE.test(text.split('\n').find((line) => line.startsWith('@@')) || '')
1274
+ && isUnifiedHunkCountError(err);
1275
+ }
1276
+
1277
+ function planApplyPatchMutationRoute(args, patchStr, requestedFormat) {
1278
+ const v4aInput = isV4APatchInput(patchStr, requestedFormat)
1279
+ || (requestedFormat !== 'unified' && hasUnifiedBareV4AHunk(patchStr));
1280
+ return {
1281
+ sourceTool: 'apply_patch',
1282
+ engine: v4aInput ? 'v4a-patch' : 'unified-patch',
1283
+ reason: 'direct',
1284
+ };
1285
+ }
1286
+
1287
+ function wrapPatchMutationOutput(text, plan, extras = {}) {
1288
+ if (isPatchErrorText(text)) return text;
1289
+ return wrapMutationRouteOutput(text, plan, extras);
1290
+ }
1291
+
1292
+ function stripPatchPathMetadata(rawPath) {
1293
+ let text = String(rawPath || '').trim();
1294
+ if (!text) return '';
1295
+ const tabIdx = text.indexOf('\t');
1296
+ if (tabIdx !== -1) text = text.slice(0, tabIdx).trimEnd();
1297
+ const quote = text[0];
1298
+ if ((quote === '"' || quote === "'") && text.length > 1) {
1299
+ const end = text.indexOf(quote, 1);
1300
+ if (end > 0) text = text.slice(1, end);
1301
+ }
1302
+ return text;
1303
+ }
1304
+
1305
+ function stripV4APathHeader(line, prefix) {
1306
+ return stripPatchPathMetadata(String(line || '').slice(prefix.length));
1307
+ }
1308
+
1309
+ function normaliseV4APath(rawPath) {
1310
+ const p = stripPatchPathMetadata(rawPath);
1311
+ if (!p) return '';
1312
+ return p.replace(/^["']|["']$/g, '').replace(/\\/g, '/');
1313
+ }
1314
+
1315
+ function normaliseV4AAnchor(rawAnchor) {
1316
+ return String(rawAnchor || '').replace(/\s*@@\s*$/, '').trim();
1317
+ }
1318
+
1319
+ function stripV4AMovePathHeader(line) {
1320
+ return normaliseV4APath(String(line || '').slice(V4A_MOVE_TO_PREFIX.length));
1321
+ }
1322
+
1323
+ function isV4AEndOfFileMarker(rawLine) {
1324
+ return String(rawLine || '').trim() === V4A_EOF_MARKER;
1325
+ }
1326
+
1327
+ function v4aEnsureUpdateHunk(current, pendingAnchors) {
1328
+ return { anchors: pendingAnchors.slice(), lines: [] };
1329
+ }
1330
+
1331
+ function v4aPushBlankContextLine(currentHunk, pendingAnchors) {
1332
+ if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(null, pendingAnchors);
1333
+ currentHunk.lines.push(' ');
1334
+ return currentHunk;
1335
+ }
1336
+
1337
+ function v4aMarkHunkEndOfFile(currentHunk, finishHunk) {
1338
+ if (!currentHunk || currentHunk.lines.length === 0) {
1339
+ throw new Error('V4A update hunk does not contain any lines before *** End of File');
1340
+ }
1341
+ currentHunk.isEndOfFile = true;
1342
+ finishHunk();
1343
+ }
1344
+
1345
+ // Split a patch string into lines, dropping the single trailing empty element
1346
+ // produced by the patch's terminal newline. Invariant: a well-formed patch
1347
+ // ends with "\n", so `"...\n".split("\n")` always yields a spurious final ""
1348
+ // that is a line *terminator*, not a content line. Absorbing it as a blank
1349
+ // context line corrupts the last hunk (phantom trailing "" in oldLines) and
1350
+ // breaks anchoring whenever the matched source region is not followed by a
1351
+ // blank line. A genuine trailing blank context line survives as the
1352
+ // second-to-last element, so only the terminator artifact is removed.
1353
+ function splitPatchLines(patchStr) {
1354
+ const lines = prepareInput(patchStr).split('\n');
1355
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
1356
+ return lines;
1357
+ }
1358
+
1359
+ function parseV4APatch(patchStr) {
1360
+ const lines = splitPatchLines(patchStr);
1361
+ const files = [];
1362
+ let current = null;
1363
+ let pendingAnchors = [];
1364
+ let currentHunk = null;
1365
+
1366
+ const finishHunk = () => {
1367
+ if (!current || !currentHunk) return;
1368
+ if (currentHunk.lines.length > 0) current.hunks.push(currentHunk);
1369
+ currentHunk = null;
1370
+ };
1371
+ const finishFile = () => {
1372
+ finishHunk();
1373
+ current = null;
1374
+ pendingAnchors = [];
1375
+ };
1376
+ const startFile = (kind, path) => {
1377
+ finishFile();
1378
+ current = { kind, path: normaliseV4APath(path), hunks: [], lines: [], movePath: null };
1379
+ files.push(current);
1380
+ };
1381
+
1382
+ for (const rawLine of lines) {
1383
+ if (rawLine === '*** Begin Patch' || rawLine === '*** End Patch') continue;
1384
+ if (rawLine.startsWith('*** Update File:')) {
1385
+ startFile('update', stripV4APathHeader(rawLine, '*** Update File:'));
1386
+ continue;
1387
+ }
1388
+ if (rawLine.startsWith('*** Add File:')) {
1389
+ startFile('add', stripV4APathHeader(rawLine, '*** Add File:'));
1390
+ continue;
1391
+ }
1392
+ if (rawLine.startsWith('*** Delete File:')) {
1393
+ startFile('delete', stripV4APathHeader(rawLine, '*** Delete File:'));
1394
+ continue;
1395
+ }
1396
+ if (!current) {
1397
+ throw new Error(`V4A patch line appears before a file header: ${rawLine}`);
1398
+ }
1399
+ if (current.kind === 'update' && rawLine.startsWith(V4A_MOVE_TO_PREFIX)) {
1400
+ if (current.movePath) {
1401
+ throw new Error(`V4A patch lists multiple ${V4A_MOVE_TO_PREFIX} directives for ${current.path}`);
1402
+ }
1403
+ const dest = stripV4AMovePathHeader(rawLine);
1404
+ if (!dest) throw new Error('V4A patch contains an empty move destination path');
1405
+ current.movePath = dest;
1406
+ continue;
1407
+ }
1408
+ if (current.kind === 'add') {
1409
+ current.lines.push(rawLine.startsWith('+') ? rawLine.slice(1) : rawLine);
1410
+ continue;
1411
+ }
1412
+ if (current.kind === 'delete') {
1413
+ continue;
1414
+ }
1415
+ if (rawLine === '') {
1416
+ if (currentHunk) currentHunk = v4aPushBlankContextLine(currentHunk, pendingAnchors);
1417
+ continue;
1418
+ }
1419
+ if (isV4AEndOfFileMarker(rawLine)) {
1420
+ v4aMarkHunkEndOfFile(currentHunk, finishHunk);
1421
+ currentHunk = null;
1422
+ continue;
1423
+ }
1424
+ if (rawLine.startsWith('@@')) {
1425
+ const anchor = normaliseV4AAnchor(rawLine.slice(2));
1426
+ if (currentHunk && currentHunk.lines.length > 0) finishHunk();
1427
+ pendingAnchors.push(anchor);
1428
+ currentHunk = { anchors: pendingAnchors.slice(), lines: [] };
1429
+ pendingAnchors = [];
1430
+ continue;
1431
+ }
1432
+ const tag = rawLine[0];
1433
+ if (tag !== ' ' && tag !== '-' && tag !== '+') {
1434
+ if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1435
+ pendingAnchors = [];
1436
+ currentHunk.lines.push(` ${rawLine}`);
1437
+ continue;
1438
+ }
1439
+ if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1440
+ currentHunk.lines.push(rawLine);
1441
+ }
1442
+ finishFile();
1443
+ const bad = files.find((file) => !file.path);
1444
+ if (bad) throw new Error('V4A patch contains an empty file path');
1445
+ if (files.length === 0) throw new Error('V4A patch contained no file sections');
1446
+ return files;
1447
+ }
1448
+
1449
+ function stripUnifiedV4APathHeader(line, prefix) {
1450
+ return stripDiffPrefix(normaliseV4APath(String(line || '').slice(prefix.length)));
1451
+ }
1452
+
1453
+ // Shared parser for unified-input -> V4A sections. The bare and counted
1454
+ // fallbacks are byte-identical except for (a) the error label and (b) how an
1455
+ // `@@` line yields its anchor: bare rejects counted headers and takes the raw
1456
+ // tail; counted requires a counted header and takes its capture group. The
1457
+ // difference is injected as `resolveAnchor`; everything else is shared.
1458
+ function parseUnifiedAsV4APatch(patchStr, { label, resolveAnchor }) {
1459
+ const lines = splitPatchLines(patchStr);
1460
+ const files = [];
1461
+ let current = null;
1462
+ let pendingAnchors = [];
1463
+ let currentHunk = null;
1464
+
1465
+ const finishHunk = () => {
1466
+ if (!current || !currentHunk) return;
1467
+ if (current.kind === 'update' && currentHunk.lines.length > 0) current.hunks.push(currentHunk);
1468
+ currentHunk = null;
1469
+ };
1470
+ const finishFile = () => {
1471
+ finishHunk();
1472
+ current = null;
1473
+ pendingAnchors = [];
1474
+ };
1475
+ const startFile = (oldPath, newPath) => {
1476
+ finishFile();
1477
+ const oldIsNull = DEV_NULL.test(oldPath || '');
1478
+ const newIsNull = DEV_NULL.test(newPath || '');
1479
+ const kind = oldIsNull ? 'add' : (newIsNull ? 'delete' : 'update');
1480
+ const path = kind === 'add' ? newPath : oldPath;
1481
+ current = { kind, path: normaliseV4APath(path), hunks: [], lines: [] };
1482
+ files.push(current);
1483
+ };
1484
+
1485
+ for (let i = 0; i < lines.length; i++) {
1486
+ const rawLine = lines[i];
1487
+ if (rawLine.startsWith('diff --git ') || rawLine.startsWith('index ') || rawLine.startsWith('new file mode ') || rawLine.startsWith('deleted file mode ')) {
1488
+ continue;
1489
+ }
1490
+ if (rawLine.startsWith('--- ')) {
1491
+ const next = lines[i + 1] || '';
1492
+ if (next.startsWith('+++ ')) {
1493
+ startFile(stripUnifiedV4APathHeader(rawLine, '--- '), stripUnifiedV4APathHeader(next, '+++ '));
1494
+ i++;
1495
+ continue;
1496
+ }
1497
+ // A real unified file header `--- X` is ALWAYS immediately followed by a
1498
+ // `+++ Y` line. Without the pair, outside a file this is a malformed
1499
+ // patch (keep the diagnostic); INSIDE a file it is a hunk-body deletion
1500
+ // line whose content starts with `-- ` (rawLine `--- foo`) — fall through
1501
+ // to body handling instead of misreading it as a file header.
1502
+ if (!current) throw new Error(`${label} missing +++ header after: ${rawLine}`);
1503
+ }
1504
+ if (!current) continue;
1505
+ if (rawLine === '') {
1506
+ if (current.kind !== 'update') continue;
1507
+ if (currentHunk) currentHunk = v4aPushBlankContextLine(currentHunk, pendingAnchors);
1508
+ continue;
1509
+ }
1510
+ if (current.kind === 'update' && isV4AEndOfFileMarker(rawLine)) {
1511
+ v4aMarkHunkEndOfFile(currentHunk, finishHunk);
1512
+ currentHunk = null;
1513
+ continue;
1514
+ }
1515
+ if (rawLine.startsWith('@@')) {
1516
+ const anchor = resolveAnchor(rawLine);
1517
+ if (currentHunk && currentHunk.lines.length > 0) finishHunk();
1518
+ pendingAnchors.push(anchor);
1519
+ currentHunk = { anchors: pendingAnchors.slice(), lines: [] };
1520
+ pendingAnchors = [];
1521
+ continue;
1522
+ }
1523
+ if (current.kind === 'add') {
1524
+ if (rawLine[0] === '+') current.lines.push(rawLine.slice(1));
1525
+ continue;
1526
+ }
1527
+ if (current.kind === 'delete') {
1528
+ continue;
1529
+ }
1530
+ const tag = rawLine[0];
1531
+ if (tag !== ' ' && tag !== '-' && tag !== '+') {
1532
+ if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1533
+ pendingAnchors = [];
1534
+ currentHunk.lines.push(` ${rawLine}`);
1535
+ continue;
1536
+ }
1537
+ if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1538
+ currentHunk.lines.push(rawLine);
1539
+ }
1540
+ finishFile();
1541
+ const bad = files.find((file) => !file.path);
1542
+ if (bad) throw new Error(`${label} contains an empty file path`);
1543
+ if (files.length === 0) throw new Error(`${label} contained no file sections`);
1544
+ return files;
1545
+ }
1546
+
1547
+ function parseUnifiedBareV4APatch(patchStr) {
1548
+ return parseUnifiedAsV4APatch(patchStr, {
1549
+ label: 'unified bare patch',
1550
+ resolveAnchor: (rawLine) => {
1551
+ if (UNIFIED_HUNK_HEADER_RE.test(rawLine)) {
1552
+ throw new Error('unified bare patch cannot mix counted unified hunks with bare @@ anchors');
1553
+ }
1554
+ return normaliseV4AAnchor(rawLine.slice(2));
1555
+ },
1556
+ });
1557
+ }
1558
+
1559
+ function parseUnifiedCountedAsV4APatch(patchStr) {
1560
+ return parseUnifiedAsV4APatch(patchStr, {
1561
+ label: 'unified fallback',
1562
+ resolveAnchor: (rawLine) => {
1563
+ const match = UNIFIED_HUNK_HEADER_CAPTURE_RE.exec(rawLine);
1564
+ if (!match) throw new Error(`unified fallback requires counted hunk header: ${rawLine}`);
1565
+ return normaliseV4AAnchor(match[1] || '');
1566
+ },
1567
+ });
1568
+ }
1569
+
1570
+ function splitTextLinesForPatch(text) {
1571
+ const body = String(text ?? '').replace(/\r\n/g, '\n');
1572
+ if (body.length === 0) {
1573
+ const empty = [];
1574
+ // No content -> no final source line that could lack a newline.
1575
+ empty.hasFinalNewline = true;
1576
+ return empty;
1577
+ }
1578
+ const lines = body.split('\n');
1579
+ // Per-line hasNewline tracking (mirror native): every line carries a trailing
1580
+ // newline EXCEPT possibly the final one. The metadata rides on the array as a
1581
+ // non-indexed `hasFinalNewline` property so the return value stays a plain
1582
+ // string[] for every existing exact/ws/normalize/LCS consumer; only the
1583
+ // newline-aware matcher reads it. When `body` ends in '\n', split yields a
1584
+ // trailing '' sentinel -> popped -> all real lines had newlines. Otherwise
1585
+ // the final element IS real content with no trailing newline.
1586
+ let hasFinalNewline = true;
1587
+ if (lines[lines.length - 1] === '') lines.pop();
1588
+ else hasFinalNewline = false;
1589
+ lines.hasFinalNewline = hasFinalNewline;
1590
+ return lines;
1591
+ }
1592
+
1593
+ // Byte-aware variant of splitTextLinesForPatch for the DIAGNOSTIC matcher only.
1594
+ // Takes a raw file Buffer and yields an array of per-line Buffer slices split on
1595
+ // 0x0A (LF), stripping a single trailing 0x0D (CR) per line so CRLF files behave
1596
+ // like the string splitter's \r\n -> \n normalization — all WITHOUT lossy UTF-8
1597
+ // decoding, so invalid bytes survive for native-parity raw byte compares. The
1598
+ // same non-indexed `hasFinalNewline` metadata rides on the array; values are
1599
+ // Buffers, which toLineBytes/unifiedOldLinesMatchAt consume directly.
1600
+ function splitBufferLinesForPatch(buf) {
1601
+ const empty = [];
1602
+ if (!buf || buf.length === 0) {
1603
+ empty.hasFinalNewline = true;
1604
+ return empty;
1605
+ }
1606
+ const lines = [];
1607
+ let start = 0;
1608
+ for (let i = 0; i < buf.length; i++) {
1609
+ if (buf[i] === 0x0a) {
1610
+ let end = i;
1611
+ if (end > start && buf[end - 1] === 0x0d) end--; // strip CR of CRLF
1612
+ lines.push(buf.subarray(start, end));
1613
+ start = i + 1;
1614
+ }
1615
+ }
1616
+ let hasFinalNewline;
1617
+ if (start === buf.length) {
1618
+ // Buffer ended exactly on a newline -> no dangling final line.
1619
+ hasFinalNewline = true;
1620
+ } else {
1621
+ // Native strips \r ONLY when immediately before \n (CRLF). The FINAL
1622
+ // unterminated line has no \n, so a bare trailing \r is KEPT in the
1623
+ // compared body to mirror native's exact byte compare.
1624
+ lines.push(buf.subarray(start, buf.length));
1625
+ hasFinalNewline = false;
1626
+ }
1627
+ lines.hasFinalNewline = hasFinalNewline;
1628
+ return lines;
1629
+ }
1630
+
1631
+ function v4AHunkLineStats(hunk) {
1632
+ let oldCount = 0;
1633
+ let newCount = 0;
1634
+ const oldLines = [];
1635
+ const newLines = [];
1636
+ for (const raw of hunk.lines || []) {
1637
+ if (!raw) continue;
1638
+ const tag = raw[0];
1639
+ const body = raw.slice(1);
1640
+ if (tag === ' ') {
1641
+ oldCount++;
1642
+ newCount++;
1643
+ oldLines.push(body);
1644
+ newLines.push(body);
1645
+ } else if (tag === '-') {
1646
+ oldCount++;
1647
+ oldLines.push(body);
1648
+ } else if (tag === '+') {
1649
+ newCount++;
1650
+ newLines.push(body);
1651
+ }
1652
+ }
1653
+ return { oldCount, newCount, oldLines, newLines };
1654
+ }
1655
+
1656
+ function findAnchorLine(lines, anchors, fromLine) {
1657
+ let cursor = Math.max(0, fromLine || 0);
1658
+ for (const anchorRaw of anchors || []) {
1659
+ const anchor = String(anchorRaw || '').trim();
1660
+ if (!anchor) continue;
1661
+ const found = lines.findIndex((line, idx) => idx >= cursor && line.includes(anchor));
1662
+ if (found === -1) return -1;
1663
+ cursor = found + 1;
1664
+ }
1665
+ return cursor;
1666
+ }
1667
+
1668
+ // Length of the longest common (contiguous) substring of `a` and `b`.
1669
+ // Capped per side so a pathological multi-KB line cannot blow up the
1670
+ // O(N*M) inner loop; lines beyond `cap` are truncated for the LCS only.
1671
+ // Used by both the long-single-line context fallback in findLineSequence
1672
+ // and the nearest-line hint scorer.
1673
+ function longestCommonSubstringLen(a, b, cap = 4000) {
1674
+ if (!a || !b) return 0;
1675
+ const A = a.length > cap ? a.slice(0, cap) : a;
1676
+ const B = b.length > cap ? b.slice(0, cap) : b;
1677
+ const la = A.length;
1678
+ const lb = B.length;
1679
+ if (la === 0 || lb === 0) return 0;
1680
+ let prev = new Int32Array(lb + 1);
1681
+ let curr = new Int32Array(lb + 1);
1682
+ let best = 0;
1683
+ for (let i = 1; i <= la; i++) {
1684
+ const ca = A.charCodeAt(i - 1);
1685
+ for (let j = 1; j <= lb; j++) {
1686
+ curr[j] = ca === B.charCodeAt(j - 1) ? prev[j - 1] + 1 : 0;
1687
+ if (curr[j] > best) best = curr[j];
1688
+ }
1689
+ const tmp = prev; prev = curr; curr = tmp;
1690
+ curr.fill(0);
1691
+ }
1692
+ return best;
1693
+ }
1694
+
1695
+ // Normalize common typographic code-points to their ASCII equivalents, then
1696
+ // trim. Mirrors the Rust mixdog-patch normalize_typographic() and Codex
1697
+ // apply_patch's normalise() so V4A->unified anchor resolution stays consistent
1698
+ // across engines: an ASCII-authored patch can still anchor on source that
1699
+ // carries curly quotes, em/en dashes, NBSP and other exotic spaces.
1700
+ // Rust str::trim() strips Unicode White_Space at both ends. JS String.trim()
1701
+ // diverges: it trims U+FEFF (BOM/ZWNBSP) but NOT U+0085 (NEL). To stay
1702
+ // byte-for-byte consistent with native normalise(), trim EXACTLY the Rust
1703
+ // White_Space set here — include U+0085, exclude U+FEFF.
1704
+ const RUST_WS = '\\u0009\\u000A\\u000B\\u000C\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000';
1705
+ const RUST_TRIM_RE = new RegExp(`^[${RUST_WS}]+|[${RUST_WS}]+$`, 'g');
1706
+ function rustTrim(s) {
1707
+ return s.replace(RUST_TRIM_RE, '');
1708
+ }
1709
+ function normalizeTypographic(s) {
1710
+ // Mirror Rust normalise() ORDER: trim FIRST, then apply the dash/quote/space
1711
+ // code-point map. Trimming before mapping matches `s.trim().chars().map(...)`.
1712
+ return rustTrim(String(s ?? ''))
1713
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-')
1714
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
1715
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
1716
+ .replace(/[\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g, ' ');
1717
+ }
1718
+
1719
+ function findLineSequence(lines, needle, fromLine, preferredLine = 0, options = {}) {
1720
+ if (!Array.isArray(needle) || needle.length === 0) return Math.max(0, preferredLine || fromLine || 0);
1721
+ const eof = options?.eof === true;
1722
+ let minStart = Math.max(0, fromLine || 0);
1723
+ if (eof && needle.length <= lines.length) {
1724
+ minStart = Math.max(minStart, lines.length - needle.length);
1725
+ }
1726
+ const preferred = Math.max(0, preferredLine || 0);
1727
+ const fuzzy = options && options.fuzzy === false ? false : true;
1728
+ const tiers = fuzzy
1729
+ ? [
1730
+ (a, b) => a === b,
1731
+ (a, b) => a.replace(/\s+$/, '') === b.replace(/\s+$/, ''),
1732
+ (a, b) => a.trim() === b.trim(),
1733
+ // Internal-whitespace-collapse tier: catches reformatted long lines
1734
+ // (e.g. re-indented JSON values) where exact bytes drift but the
1735
+ // semantic content matches. Runs strictly after stricter tiers so
1736
+ // exact / rstrip / trim still win when they match.
1737
+ (a, b) => a.replace(/\s+/g, ' ').trim() === b.replace(/\s+/g, ' ').trim(),
1738
+ // Unicode-normalization tier (LAST): typographic dashes/quotes/NBSP in
1739
+ // the source vs an ASCII-authored patch. Deterministic code-point map,
1740
+ // runs after every whitespace tier so stricter matches always win.
1741
+ (a, b) => normalizeTypographic(a) === normalizeTypographic(b),
1742
+ ]
1743
+ : [
1744
+ (a, b) => a === b,
1745
+ ];
1746
+ for (const eq of tiers) {
1747
+ const starts = [];
1748
+ for (let i = minStart; i <= lines.length - needle.length; i++) {
1749
+ let ok = true;
1750
+ for (let k = 0; k < needle.length; k++) {
1751
+ if (!eq(lines[i + k], needle[k])) { ok = false; break; }
1752
+ }
1753
+ if (ok) starts.push(i);
1754
+ }
1755
+ if (starts.length) {
1756
+ starts.sort((a, b) => Math.abs(a - preferred) - Math.abs(b - preferred) || a - b);
1757
+ return starts[0];
1758
+ }
1759
+ }
1760
+ // Long single-line context fallback. When the entire needle is one long
1761
+ // line (>=40 chars after whitespace-collapse) and every equality tier
1762
+ // failed, accept a UNIQUE source line whose longest-common-substring
1763
+ // with the needle is the file-wide maximum and covers at least half of
1764
+ // the needle. Uniqueness is the invariant: ambiguous best-matches
1765
+ // return -1, so real mismatches still surface as "context not found"
1766
+ // instead of silently anchoring on the wrong line.
1767
+ if (fuzzy && needle.length === 1) {
1768
+ const want = String(needle[0] ?? '').replace(/\s+/g, ' ').trim();
1769
+ if (want.length >= 40) {
1770
+ const minLcs = Math.max(40, Math.floor(want.length / 2));
1771
+ let bestIdx = -1;
1772
+ let bestLcs = 0;
1773
+ let bestTies = 0;
1774
+ for (let i = minStart; i < lines.length; i++) {
1775
+ const cand = String(lines[i] ?? '').replace(/\s+/g, ' ').trim();
1776
+ if (cand.length === 0) continue;
1777
+ const lcs = longestCommonSubstringLen(cand, want);
1778
+ if (lcs < minLcs) continue;
1779
+ if (lcs > bestLcs) { bestLcs = lcs; bestIdx = i; bestTies = 1; }
1780
+ else if (lcs === bestLcs) { bestTies++; }
1781
+ }
1782
+ if (bestIdx >= 0 && bestTies === 1) return bestIdx;
1783
+ }
1784
+ }
1785
+ return -1;
1786
+ }
1787
+
1788
+ function compactPatchPreviewLine(line, maxLen = 140) {
1789
+ const text = String(line ?? '').replace(/\t/g, '\\t');
1790
+ return text.length > maxLen ? `${text.slice(0, maxLen - 1)}…` : text;
1791
+ }
1792
+
1793
+ // Bundled/transpiled sources often store non-ASCII inside string literals as
1794
+ // literal `\uXXXX` escape sequences (6 ASCII chars). A patch authored with the
1795
+ // real character can then never match verbatim. These helpers let the V4A
1796
+ // locator accept a window where each patch line matches the source either
1797
+ // verbatim or via "patch's real char == file's \uXXXX escape of it".
1798
+ function escapeNonAsciiForPatch(line) {
1799
+ const s = String(line ?? '');
1800
+ let out = '';
1801
+ for (let i = 0; i < s.length; i++) {
1802
+ const code = s.charCodeAt(i);
1803
+ out += code > 0x7f
1804
+ ? String.fromCharCode(92) + 'u' + code.toString(16).padStart(4, '0')
1805
+ : s[i];
1806
+ }
1807
+ return out;
1808
+ }
1809
+
1810
+ function findLineSequenceEscapeEquiv(sourceLines, pattern, minStart, preferred) {
1811
+ if (!pattern || pattern.length === 0) return -1;
1812
+ const starts = [];
1813
+ const from = Math.max(0, Number.isFinite(minStart) ? minStart : 0);
1814
+ outer: for (let i = from; i + pattern.length <= sourceLines.length; i++) {
1815
+ let usedEquiv = false;
1816
+ for (let k = 0; k < pattern.length; k++) {
1817
+ const pat = pattern[k];
1818
+ const src = sourceLines[i + k];
1819
+ if (src === pat) continue;
1820
+ if (src === escapeNonAsciiForPatch(pat)) { usedEquiv = true; continue; }
1821
+ continue outer;
1822
+ }
1823
+ // Require at least one escape-equivalent line: an all-verbatim window
1824
+ // would have been found by the primary matcher already.
1825
+ if (usedEquiv) starts.push(i);
1826
+ }
1827
+ if (starts.length === 0) return -1;
1828
+ const pref = Number.isFinite(preferred) && preferred >= 0 ? preferred : 0;
1829
+ starts.sort((a, b) => Math.abs(a - pref) - Math.abs(b - pref) || a - b);
1830
+ return starts[0];
1831
+ }
1832
+
1833
+ function firstMeaningfulPatchLine(lines) {
1834
+ return (lines || []).find((line) => String(line ?? '').trim().length > 0) || '';
1835
+ }
1836
+
1837
+ function scoreSimilarPatchLine(candidate, target) {
1838
+ const cand = String(candidate ?? '').trim().replace(/\s+/g, ' ');
1839
+ const want = String(target ?? '').trim().replace(/\s+/g, ' ');
1840
+ if (!cand || !want) return 0;
1841
+ if (cand === want) return 100000;
1842
+ let score = 0;
1843
+ // Longest common substring drives similarity for long lines: weighting
1844
+ // shared-byte run length keeps the "nearest line" hint anchored on the
1845
+ // line that actually shares the most content, instead of a short line
1846
+ // that happens to embed in (or share a few tokens with) the long target.
1847
+ const lcs = longestCommonSubstringLen(cand, want);
1848
+ score += lcs * 20;
1849
+ if (cand.includes(want) || want.includes(cand)) score += 5000 + Math.min(cand.length, want.length);
1850
+ const words = new Set(want.split(/[^A-Za-z0-9_$]+/).filter((word) => word.length > 1));
1851
+ for (const word of words) {
1852
+ if (cand.includes(word)) score += Math.min(200, word.length * 12);
1853
+ }
1854
+ // Length-delta penalty only meaningful for short lines; a long line with
1855
+ // a large shared-byte run should not be crushed by a modest length gap.
1856
+ if (Math.max(cand.length, want.length) < 80) {
1857
+ score -= Math.abs(cand.length - want.length);
1858
+ }
1859
+ return score;
1860
+ }
1861
+
1862
+ function nearestPatchLineHint(sourceLines, expectedLine, preferredLine) {
1863
+ const expected = String(expectedLine || '');
1864
+ if (!expected.trim()) return '';
1865
+ let best = null;
1866
+ const preferred = Number.isFinite(preferredLine) && preferredLine >= 0 ? preferredLine : 0;
1867
+ for (let i = 0; i < sourceLines.length; i++) {
1868
+ const score = scoreSimilarPatchLine(sourceLines[i], expected) - (Math.abs(i - preferred) * 0.01);
1869
+ if (!best || score > best.score) best = { score, index: i, line: sourceLines[i] };
1870
+ }
1871
+ if (!best || best.score <= 0) return '';
1872
+ return `nearest line ${best.index + 1}: ${JSON.stringify(compactPatchPreviewLine(best.line))}`;
1873
+ }
1874
+
1875
+ function formatV4AHunkLocator(hunk) {
1876
+ return (hunk.anchors || []).filter(Boolean).join(' > ') || '(no anchor)';
1877
+ }
1878
+
1879
+ function formatV4AAnchorMissHint(sourceLines, hunk) {
1880
+ const anchors = (hunk?.anchors || []).filter(Boolean);
1881
+ const nearest = anchors.length > 0
1882
+ ? anchors.map((anchor) => nearestPatchLineHint(sourceLines, anchor, 0)).find(Boolean)
1883
+ : null;
1884
+ return anchors.length === 0
1885
+ ? ' use an existing @@ anchor from the current file or add exact context lines.'
1886
+ : ` use an existing @@ anchor from the current file or add exact context lines; no stubs.${nearest ? ` nearest anchor candidate: ${nearest}.` : ''}`;
1887
+ }
1888
+
1889
+ function formatV4AContextMissHint(sourceLines, stats, anchorLine) {
1890
+ const expected = firstMeaningfulPatchLine(stats.oldLines);
1891
+ const parts = [];
1892
+ if (expected) {
1893
+ const nearest = nearestPatchLineHint(sourceLines, expected, anchorLine);
1894
+ parts.push(`expected first old line: ${JSON.stringify(compactPatchPreviewLine(expected))}`);
1895
+ if (nearest) parts.push(nearest);
1896
+ const divergence = firstV4ADivergenceHint(sourceLines, stats.oldLines, anchorLine);
1897
+ if (divergence) parts.push(divergence);
1898
+ }
1899
+ parts.push('use exact current context or a broader @@ anchor; no stubs.');
1900
+ return ` ${parts.join('; ')}`;
1901
+ }
1902
+
1903
+ // When the FIRST old line does exist verbatim in the source, the real
1904
+ // mismatch is some later line of the block — name it, with both sides
1905
+ // JSON-escaped so invisible differences (real char vs literal \uXXXX
1906
+ // escape, tabs, trailing spaces) become visible in the error.
1907
+ function firstV4ADivergenceHint(sourceLines, oldLines, anchorLine) {
1908
+ const lines = oldLines || [];
1909
+ const firstIdx = lines.findIndex((l) => String(l ?? '').trim().length > 0);
1910
+ if (firstIdx < 0) return '';
1911
+ const first = lines[firstIdx];
1912
+ const starts = [];
1913
+ for (let i = 0; i < sourceLines.length; i++) {
1914
+ if (sourceLines[i] === first) starts.push(i - firstIdx);
1915
+ }
1916
+ const pref = Number.isFinite(anchorLine) && anchorLine >= 0 ? anchorLine : 0;
1917
+ const start = starts.filter((s) => s >= 0)
1918
+ .sort((a, b) => Math.abs(a - pref) - Math.abs(b - pref) || a - b)[0];
1919
+ if (start === undefined) return '';
1920
+ for (let k = 0; k < lines.length; k++) {
1921
+ const exp = lines[k];
1922
+ const act = sourceLines[start + k];
1923
+ if (act !== exp) {
1924
+ const actText = act === undefined ? '(past EOF)' : JSON.stringify(compactPatchPreviewLine(act));
1925
+ return `first divergent line: old[${k + 1}] expected ${JSON.stringify(compactPatchPreviewLine(exp))} vs file line ${start + k + 1} actual ${actText}`;
1926
+ }
1927
+ }
1928
+ return '';
1929
+ }
1930
+
1931
+ function joinTextLinesForPatch(lines) {
1932
+ const body = (lines || []).join('\n');
1933
+ return lines?.hasFinalNewline !== false ? `${body}\n` : body;
1934
+ }
1935
+
1936
+ function cloneTextLinesForPatch(sourceLines) {
1937
+ const lines = [...(sourceLines || [])];
1938
+ lines.hasFinalNewline = sourceLines?.hasFinalNewline !== false;
1939
+ return lines;
1940
+ }
1941
+
1942
+ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {}) {
1943
+ const stats = v4AHunkLineStats(hunk);
1944
+ if (stats.oldCount === 0 && stats.newCount === 0) return { skip: true };
1945
+ const fuzzy = options.fuzzy !== false;
1946
+ const eof = hunk?.isEndOfFile === true;
1947
+ const anchorLine = findAnchorLine(sourceLines, hunk.anchors, nextSearchLine);
1948
+ if (anchorLine < 0) {
1949
+ const msg = `V4A hunk anchor not found: ${formatV4AHunkLocator(hunk)};${formatV4AAnchorMissHint(sourceLines, hunk)}`;
1950
+ return { error: msg };
1951
+ }
1952
+ let oldLinesPattern = stats.oldLines;
1953
+ let newLinesPattern = stats.newLines;
1954
+ let oldStartIdx;
1955
+ let trimmedTrailing = 0;
1956
+ let trimmedTrailingNew = 0;
1957
+ if (stats.oldCount === 0) {
1958
+ oldStartIdx = eof ? sourceLines.length : anchorLine;
1959
+ } else {
1960
+ const searchFrom = Math.max(0, anchorLine - 1);
1961
+ oldStartIdx = findLineSequence(
1962
+ sourceLines,
1963
+ oldLinesPattern,
1964
+ searchFrom,
1965
+ searchFrom,
1966
+ { fuzzy, eof },
1967
+ );
1968
+ if (eof && oldStartIdx < 0 && oldLinesPattern.length > 0 && oldLinesPattern[oldLinesPattern.length - 1] === '') {
1969
+ oldLinesPattern = oldLinesPattern.slice(0, -1);
1970
+ trimmedTrailing = 1;
1971
+ if (newLinesPattern.length > 0 && newLinesPattern[newLinesPattern.length - 1] === '') {
1972
+ newLinesPattern = newLinesPattern.slice(0, -1);
1973
+ trimmedTrailingNew = 1;
1974
+ }
1975
+ oldStartIdx = findLineSequence(
1976
+ sourceLines,
1977
+ oldLinesPattern,
1978
+ searchFrom,
1979
+ searchFrom,
1980
+ { fuzzy, eof },
1981
+ );
1982
+ }
1983
+ }
1984
+ // Escape-equivalence fallback (fuzzy, non-EOF only): accept a window where each old
1985
+ // line matches the source verbatim OR as the file's literal `\uXXXX` escape
1986
+ // of the patch's real character. On match, remap old/context lines to the
1987
+ // file's on-disk form so untouched context stays byte-identical and the
1988
+ // escape representation survives the edit.
1989
+ if (oldStartIdx < 0 && fuzzy && !eof && oldLinesPattern.length > 0) {
1990
+ const from = Math.max(0, anchorLine - 1);
1991
+ const alt = findLineSequenceEscapeEquiv(sourceLines, oldLinesPattern, from, from);
1992
+ if (alt >= 0) {
1993
+ const remapped = new Map();
1994
+ // Text-keyed remap is only safe when unambiguous: if the SAME patch
1995
+ // line text maps to DIFFERENT on-disk forms at different window
1996
+ // positions (one verbatim, one escaped), rewriting newLines by text
1997
+ // would corrupt an untouched context line — reject the match instead.
1998
+ let ambiguous = false;
1999
+ for (let k = 0; k < oldLinesPattern.length; k++) {
2000
+ const pat = oldLinesPattern[k];
2001
+ const src = sourceLines[alt + k];
2002
+ if (remapped.has(pat) && remapped.get(pat) !== src) { ambiguous = true; break; }
2003
+ remapped.set(pat, src);
2004
+ }
2005
+ if (!ambiguous) {
2006
+ newLinesPattern = newLinesPattern.map((l) => remapped.get(l) ?? l);
2007
+ oldLinesPattern = oldLinesPattern.map((_, k) => sourceLines[alt + k]);
2008
+ oldStartIdx = alt;
2009
+ }
2010
+ }
2011
+ }
2012
+ if (oldStartIdx < 0) {
2013
+ const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}`;
2014
+ return { error: msg };
2015
+ }
2016
+ const matchLen = stats.oldCount === 0 ? 0 : oldLinesPattern.length;
2017
+ return {
2018
+ oldStartIdx,
2019
+ matchLen,
2020
+ newLines: newLinesPattern,
2021
+ nextSearchLine: oldStartIdx + Math.max(1, matchLen),
2022
+ trimmedTrailing,
2023
+ trimmedTrailingNew,
2024
+ };
2025
+ }
2026
+
2027
+ function applyV4AHunksToLines(sourceLines, hunks, options = {}) {
2028
+ const lines = cloneTextLinesForPatch(sourceLines);
2029
+ const orderedHunks = orderV4AHunksByFilePosition(lines, hunks, options.fuzzy !== false);
2030
+ let nextSearchLine = 0;
2031
+ const replacements = [];
2032
+ for (const hunk of orderedHunks) {
2033
+ const loc = resolveV4AHunkPosition(lines, hunk, nextSearchLine, options);
2034
+ if (loc.skip) continue;
2035
+ if (loc.error) throw new Error(loc.error);
2036
+ replacements.push({
2037
+ oldStartIdx: loc.oldStartIdx,
2038
+ oldLen: loc.matchLen,
2039
+ newLines: loc.newLines,
2040
+ });
2041
+ nextSearchLine = loc.nextSearchLine;
2042
+ }
2043
+ for (const rep of replacements.reverse()) {
2044
+ lines.splice(rep.oldStartIdx, rep.oldLen, ...rep.newLines);
2045
+ }
2046
+ return lines;
2047
+ }
2048
+
2049
+ // Order-independent hunk ordering for the V4A apply / V4A→unified conversion.
2050
+ // V4A hunks carry no line numbers and may be authored out of file order (a
2051
+ // later edit's hunk listed before an earlier one) or against pre-shift line
2052
+ // numbers. The cursor loops that consume hunks locate each one with a
2053
+ // forward-only `nextSearchLine`, which rejects an out-of-order hunk even when
2054
+ // its context is uniquely present ("context not found; nearest line N").
2055
+ //
2056
+ // Two-phase, semantics-preserving:
2057
+ // Phase 1 — replay the SAME forward-cursor over the input order. If every
2058
+ // hunk resolves, the existing cursor semantics are authoritative — they
2059
+ // own duplicate-context AND insert-only @@-anchor disambiguation (a later
2060
+ // hunk binds to the NEXT matching occurrence after the previous hunk), so
2061
+ // we return the hunks UNCHANGED. An already-in-order patch is a guaranteed
2062
+ // no-op; nothing about the existing behaviour shifts.
2063
+ // Phase 2 (invariant-based recovery) — only when the input order is NOT
2064
+ // forward-locatable (a hunk targets a position before a prior hunk).
2065
+ // Reorder a hunk ONLY when its old-block (context+delete body lines)
2066
+ // appears EXACTLY ONCE in the source as a literal line sequence — that
2067
+ // hunk then has a single order-independent position. If ANY hunk is
2068
+ // insert-only (no old body) or its old-block is absent / appears more than
2069
+ // once (cursor-sensitive), reordering is unsafe: return the input order
2070
+ // unchanged so the loop surfaces the original error instead of binding to
2071
+ // the wrong occurrence. Direct literal counting (NOT resolveV4AHunkPosition)
2072
+ // sidesteps the anchor/cursor/EOF off-by-one quirks of a re-probe.
2073
+ function orderV4AHunksByFilePosition(sourceLines, hunks, fuzzy) {
2074
+ const list = hunks || [];
2075
+ if (list.length <= 1) return list;
2076
+ // Phase 1: is the input order already forward-locatable? Mirror the
2077
+ // conversion/apply loop's `nextSearchLine` advance exactly.
2078
+ let nextSearchLine = 0;
2079
+ let inputOrderValid = true;
2080
+ for (const hunk of list) {
2081
+ const stats = v4AHunkLineStats(hunk);
2082
+ if (stats.oldCount === 0 && stats.newCount === 0) continue;
2083
+ let loc;
2084
+ try { loc = resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, { fuzzy }); }
2085
+ catch { loc = { error: true }; }
2086
+ if (!loc || loc.error || loc.skip || typeof loc.nextSearchLine !== 'number') {
2087
+ inputOrderValid = false;
2088
+ break;
2089
+ }
2090
+ nextSearchLine = loc.nextSearchLine;
2091
+ }
2092
+ if (inputOrderValid) return list;
2093
+ // Phase 2: reorder only hunks whose old-block is a UNIQUE literal sequence.
2094
+ const keyed = [];
2095
+ for (let idx = 0; idx < list.length; idx++) {
2096
+ const hunk = list[idx];
2097
+ const stats = v4AHunkLineStats(hunk);
2098
+ if (stats.oldCount === 0 && stats.newCount === 0) {
2099
+ keyed.push({ hunk, key: Number.MAX_SAFE_INTEGER, idx });
2100
+ continue;
2101
+ }
2102
+ // Old-block = context + delete body lines (prefix-stripped), excluding the
2103
+ // EOF marker. Empty = insert-only → no order-independent position.
2104
+ const seq = [];
2105
+ for (const ln of hunk.lines || []) {
2106
+ if (isV4AEndOfFileMarker(ln)) continue;
2107
+ const p = ln[0];
2108
+ if (p === ' ' || p === '-') seq.push(ln.slice(1));
2109
+ }
2110
+ if (seq.length === 0) return list;
2111
+ // Count exact file-wide occurrences (early-out at 2). Must be exactly one.
2112
+ let pos = -1;
2113
+ let count = 0;
2114
+ for (let i = 0; i + seq.length <= sourceLines.length; i++) {
2115
+ let match = true;
2116
+ for (let j = 0; j < seq.length; j++) {
2117
+ if (sourceLines[i + j] !== seq[j]) { match = false; break; }
2118
+ }
2119
+ if (match) {
2120
+ if (pos < 0) pos = i;
2121
+ count++;
2122
+ if (count >= 2) break;
2123
+ }
2124
+ }
2125
+ if (count !== 1) return list;
2126
+ keyed.push({ hunk, key: pos, idx });
2127
+ }
2128
+ keyed.sort((a, b) => (a.key - b.key) || (a.idx - b.idx));
2129
+ return keyed.map((e) => e.hunk);
2130
+ }
2131
+
2132
+ function isV4ARenameSection(section) {
2133
+ return section?.kind === 'update' && !!section?.movePath;
2134
+ }
2135
+
2136
+ function v4aRenamePathKey(absPath) {
2137
+ return process.platform === 'win32' ? String(absPath || '').toLowerCase() : String(absPath || '');
2138
+ }
2139
+
2140
+ function v4aRenamePathInsideRealBase(absPath, realBase) {
2141
+ const checkReal = realpathNearestExistingAncestor(absPath);
2142
+ const checkRel = pathRelative(realBase, checkReal);
2143
+ if (!checkRel || checkRel.startsWith('..') || isAbsolute(checkRel)) return false;
2144
+ return !checkRel.split(/[\\/]+/).some((part) => part === '..');
2145
+ }
2146
+
2147
+ function validateV4ARenameSection(section, basePath, seenDestKeys, realBase) {
2148
+ const escapeErr = v4aSectionPathEscapeError(section, basePath);
2149
+ if (escapeErr) return escapeErr;
2150
+ const escapeDest = v4aSectionPathEscapeError({ path: section.movePath }, basePath);
2151
+ if (escapeDest) return escapeDest;
2152
+ const srcFull = resolveV4AEntryPath(basePath, section.path);
2153
+ const destFull = resolveV4AEntryPath(basePath, section.movePath);
2154
+ if (realBase) {
2155
+ if (!v4aRenamePathInsideRealBase(srcFull, realBase)) {
2156
+ return `apply_patch: ${normalizeOutputPath(section.path)} resolves outside base_path via symlink/junction; refusing V4A rename.`;
2157
+ }
2158
+ if (!v4aRenamePathInsideRealBase(destFull, realBase)) {
2159
+ return `apply_patch: ${normalizeOutputPath(section.movePath)} resolves outside base_path via symlink/junction; refusing V4A rename.`;
2160
+ }
2161
+ }
2162
+ if (v4aRenamePathKey(srcFull) === v4aRenamePathKey(destFull)) {
2163
+ return `apply_patch: V4A rename source and destination are the same path (${normalizeOutputPath(section.path)})`;
2164
+ }
2165
+ const destKey = v4aRenamePathKey(destFull);
2166
+ if (seenDestKeys.has(destKey)) {
2167
+ return `apply_patch: duplicate V4A rename destination ${normalizeOutputPath(section.movePath)}`;
2168
+ }
2169
+ seenDestKeys.add(destKey);
2170
+ try {
2171
+ const st = statSync(srcFull);
2172
+ if (!st.isFile()) {
2173
+ return `apply_patch: V4A rename source is not a regular file: ${normalizeOutputPath(section.path)}`;
2174
+ }
2175
+ } catch (err) {
2176
+ return `apply_patch: V4A rename source missing or unreadable: ${normalizeOutputPath(section.path)} (${err?.code || err?.message || String(err)})`;
2177
+ }
2178
+ try {
2179
+ const destSt = statSync(destFull);
2180
+ if (destSt.isDirectory()) {
2181
+ return `apply_patch: V4A rename destination is a directory: ${normalizeOutputPath(section.movePath)}`;
2182
+ }
2183
+ if (!destSt.isFile()) {
2184
+ return `apply_patch: V4A rename destination is not a regular file: ${normalizeOutputPath(section.movePath)}`;
2185
+ }
2186
+ } catch (err) {
2187
+ if (err?.code !== 'ENOENT') {
2188
+ return `apply_patch: V4A rename destination unreadable: ${normalizeOutputPath(section.movePath)} (${err?.code || err?.message || String(err)})`;
2189
+ }
2190
+ }
2191
+ if (!section.hunks?.length) {
2192
+ return `apply_patch: V4A rename for ${normalizeOutputPath(section.path)} has no update hunks`;
2193
+ }
2194
+ return null;
2195
+ }
2196
+
2197
+ async function applyV4ARenameSection(section, basePath, options = {}) {
2198
+ const srcFull = resolveV4AEntryPath(basePath, section.path);
2199
+ const destFull = resolveV4AEntryPath(basePath, section.movePath);
2200
+ const displaySrc = normalizeOutputPath(section.path);
2201
+ const displayDest = normalizeOutputPath(section.movePath);
2202
+ let sourceLines;
2203
+ try {
2204
+ sourceLines = v4aConversionSourceLines(srcFull, options.linesCache || new Map());
2205
+ } catch (err) {
2206
+ throw new Error(`apply_patch: V4A rename source unreadable: ${displaySrc} (${err?.code || err?.message || String(err)})`);
2207
+ }
2208
+ let updatedLines;
2209
+ try {
2210
+ updatedLines = applyV4AHunksToLines(sourceLines, section.hunks, options);
2211
+ } catch (err) {
2212
+ throw err;
2213
+ }
2214
+ const newContent = joinTextLinesForPatch(updatedLines);
2215
+ if (options.dryRun) {
2216
+ return {
2217
+ ok: true,
2218
+ dryRun: true,
2219
+ displayPath: displayDest,
2220
+ linesChanged: section.hunks.reduce((n, h) => n + (h.lines?.length || 0), 0),
2221
+ srcFull,
2222
+ destFull,
2223
+ };
2224
+ }
2225
+ const originalContent = readFileSync(srcFull);
2226
+ let destBefore = null;
2227
+ try {
2228
+ destBefore = readFileSync(destFull);
2229
+ } catch (err) {
2230
+ if (err?.code !== 'ENOENT') throw err;
2231
+ }
2232
+ mkdirSync(pathDirname(destFull), { recursive: true });
2233
+ try {
2234
+ await atomicWrite(destFull, newContent, { sessionId: options.readStateScope });
2235
+ await unlink(srcFull);
2236
+ } catch (err) {
2237
+ try {
2238
+ if (destBefore === null) {
2239
+ try { await unlink(destFull); } catch {}
2240
+ } else {
2241
+ await atomicWrite(destFull, destBefore, { sessionId: options.readStateScope });
2242
+ }
2243
+ } catch {}
2244
+ try {
2245
+ await atomicWrite(srcFull, originalContent, { sessionId: options.readStateScope });
2246
+ } catch {}
2247
+ throw new Error(`apply_patch: V4A rename failed for ${displaySrc} → ${displayDest} (${err?.message || String(err)})`);
2248
+ }
2249
+ invalidateBuiltinResultCache([srcFull, destFull]);
2250
+ markCodeGraphDirtyPaths([srcFull, destFull]);
2251
+ clearReadSnapshotForPath(srcFull, options.readStateScope);
2252
+ clearReadSnapshotForPath(destFull, options.readStateScope);
2253
+ return {
2254
+ ok: true,
2255
+ displayPath: displayDest,
2256
+ fromPath: displaySrc,
2257
+ linesChanged: section.hunks.reduce((n, h) => n + (h.lines?.length || 0), 0),
2258
+ srcFull,
2259
+ destFull,
2260
+ };
2261
+ }
2262
+
2263
+ function formatV4ARenameSuccessLines(results) {
2264
+ return (results || [])
2265
+ .filter((r) => r?.ok && !r.skipped)
2266
+ .map((r) => `OK ${r.displayPath} (renamed from ${r.fromPath}, ~${r.linesChanged} lines touched, engine=v4a-rename)`);
2267
+ }
2268
+
2269
+ async function planV4ARenameSections(sections, basePath) {
2270
+ const renameSections = (sections || []).filter(isV4ARenameSection);
2271
+ const remainingSections = (sections || []).filter((s) => !isV4ARenameSection(s));
2272
+ if (renameSections.length === 0) {
2273
+ return { renameSections: [], remainingSections };
2274
+ }
2275
+ if (renameSections.length > 1) {
2276
+ throw new Error('apply_patch: only one V4A rename (*** Move to:) per patch is supported; split into separate patches.');
2277
+ }
2278
+ if (remainingSections.length > 0) {
2279
+ throw new Error('apply_patch: V4A rename cannot be combined with other add/update/delete sections in the same patch; apply file edits in a separate patch first.');
2280
+ }
2281
+ await assertPathReachable(basePath);
2282
+ const renameReachPaths = renameSections.flatMap((section) => [
2283
+ resolveV4AEntryPath(basePath, section.path),
2284
+ resolveV4AEntryPath(basePath, section.movePath),
2285
+ ]);
2286
+ await assertPathsReachable(renameReachPaths);
2287
+ let realBase;
2288
+ try {
2289
+ realBase = realpathSync(pathResolve(basePath));
2290
+ } catch (err) {
2291
+ throw new Error(`apply_patch: base_path unreadable (${err?.code || err?.message || String(err)}): ${basePath}`);
2292
+ }
2293
+ const seenDestKeys = new Set();
2294
+ for (const section of renameSections) {
2295
+ const errText = validateV4ARenameSection(section, basePath, seenDestKeys, realBase);
2296
+ if (errText) throw new Error(errText);
2297
+ }
2298
+ return {
2299
+ renameSections,
2300
+ remainingSections,
2301
+ };
2302
+ }
2303
+
2304
+ async function applyV4ARenameSections(renameSections, basePath, options = {}) {
2305
+ const linesCache = new Map();
2306
+ const results = [];
2307
+ for (const section of renameSections || []) {
2308
+ results.push(await applyV4ARenameSection(section, basePath, { ...options, linesCache }));
2309
+ }
2310
+ return results;
2311
+ }
2312
+
2313
+ function convertV4AToUnifiedPatch(patchStr, basePath, options = {}) {
2314
+ const sections = parseV4APatch(patchStr).filter((section) => !isV4ARenameSection(section));
2315
+ return convertV4ASectionsToUnifiedPatch(sections, basePath, options);
2316
+ }
2317
+
2318
+ function convertUnifiedBareV4AToUnifiedPatch(patchStr, basePath, options = {}) {
2319
+ return convertV4ASectionsToUnifiedPatch(parseUnifiedBareV4APatch(patchStr), basePath, options);
2320
+ }
2321
+
2322
+ function convertUnifiedCountedToUnifiedPatchViaV4A(patchStr, basePath, options = {}) {
2323
+ return convertV4ASectionsToUnifiedPatch(parseUnifiedCountedAsV4APatch(patchStr), basePath, options);
2324
+ }
2325
+
2326
+ // Lexical path-escape guard for V4A section paths. Mirrors the check
2327
+ // `nativeHeaderSupported` runs on unified headers: a `..` segment or an
2328
+ // absolute path that does not resolve inside basePath is unsupported.
2329
+ // Run BEFORE the V4A readFileSync so an escape surfaces with a clear
2330
+ // reason instead of masquerading as an ENOENT "update target unreadable".
2331
+ function v4aSectionPathEscapeError(section, basePath) {
2332
+ const raw = section?.path;
2333
+ if (!raw) return null;
2334
+ const norm = normalizeInputPath(raw);
2335
+ const segs = norm.split(/[\\/]+/);
2336
+ if (segs.some((part) => part === '..')) {
2337
+ return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
2338
+ }
2339
+ if (isAbsolute(norm) || /^[A-Za-z]:[\\/]/.test(norm)) {
2340
+ if (!basePath) return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
2341
+ const absHeader = pathResolve(norm);
2342
+ const absBase = pathResolve(basePath);
2343
+ const rel = pathRelative(absBase, absHeader);
2344
+ if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(/[\\/]+/).some((part) => part === '..')) {
2345
+ return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
2346
+ }
2347
+ }
2348
+ return null;
2349
+ }
2350
+
2351
+ function readRawBufForV4AConversion(fullPath) {
2352
+ // Fresh statSync (NOT the 5s STAT_CACHE) for raw-cache generation validation:
2353
+ // an external modify/delete that bypasses invalidateBuiltinResultCache could
2354
+ // otherwise let a stale STAT_CACHE entry match stale raw bytes, anchoring V4A
2355
+ // hunks on out-of-date source. Fresh stat is cheap and keeps the byte-read
2356
+ // savings on the unchanged common path while rejecting stale generations.
2357
+ const st = statSync(fullPath);
2358
+ const cached = rawContentCacheGet(fullPath, st);
2359
+ if (cached) return cached;
2360
+ const rawBuf = readFileSync(fullPath);
2361
+ const buf = Buffer.isBuffer(rawBuf) ? rawBuf : Buffer.from(rawBuf);
2362
+ rawContentCacheSet(fullPath, st, buf);
2363
+ return buf;
2364
+ }
2365
+
2366
+ function v4aConversionSourceLines(fullPath, linesCache) {
2367
+ if (linesCache.has(fullPath)) return linesCache.get(fullPath);
2368
+ const lines = splitTextLinesForPatch(readRawBufForV4AConversion(fullPath).toString('utf-8'));
2369
+ linesCache.set(fullPath, lines);
2370
+ return lines;
2371
+ }
2372
+
2373
+ // options.rejectPartial (default true)
2374
+ // true — anchor/context miss on any hunk throws and aborts the whole patch
2375
+ // false — hunk-level isolation in the V4A→unified conversion: a hunk
2376
+ // whose anchor/context cannot be located is dropped; the rest of
2377
+ // the file's hunks continue. A file section whose hunks all fail
2378
+ // emits no header so the downstream unified-diff parser does not
2379
+ // see an empty section. Dropped hunks are appended to
2380
+ // options.rejectedHunks for the caller to surface.
2381
+ async function convertV4ASectionsToUnifiedPatch(sections, basePath, options = {}) {
2382
+ // Reachability preflight for update/delete targets: v4aConversionSourceLines
2383
+ // does statSync/readFileSync on each non-add section's source file. A
2384
+ // dead-mounted target under a reachable basePath would freeze the event loop
2385
+ // here, before preValidateNativeBatch's guard. resolveEntryPath is FS-pure.
2386
+ {
2387
+ const reachPaths = [];
2388
+ const _seenReach = new Set();
2389
+ for (const s of (sections || [])) {
2390
+ if (!s || s.kind === 'add' || typeof s.path !== 'string' || !s.path) continue;
2391
+ const fp = resolveV4AEntryPath(basePath, s.path);
2392
+ if (_seenReach.has(fp)) continue;
2393
+ _seenReach.add(fp);
2394
+ reachPaths.push(fp);
2395
+ }
2396
+ await assertPathsReachable(reachPaths);
2397
+ }
2398
+ const rejectPartial = options.rejectPartial !== false;
2399
+ const rejectedHunks = Array.isArray(options.rejectedHunks) ? options.rejectedHunks : null;
2400
+ const fuzzy = options.fuzzy !== false;
2401
+ const out = [];
2402
+ const v4aLinesCache = new Map();
2403
+ for (const section of sections) {
2404
+ // Explicit path-escape guard runs BEFORE any readFileSync attempt so
2405
+ // a header containing `..` or an out-of-base absolute path surfaces
2406
+ // with a clear reason instead of being masked as ENOENT (the V4A
2407
+ // "update target unreadable" path) when the escaped target doesn't
2408
+ // happen to exist on disk.
2409
+ const escapeErr = v4aSectionPathEscapeError(section, basePath);
2410
+ if (escapeErr) throw new Error(escapeErr);
2411
+ const displayPath = section.path.replace(/\\/g, '/');
2412
+ if (section.kind === 'add') {
2413
+ out.push('--- /dev/null');
2414
+ out.push(`+++ b/${displayPath}`);
2415
+ out.push(`@@ -0,0 +1,${section.lines.length} @@`);
2416
+ for (const line of section.lines) out.push(`+${line}`);
2417
+ continue;
2418
+ }
2419
+ if (section.kind === 'delete') {
2420
+ const fullPath = resolveV4AEntryPath(basePath, section.path);
2421
+ let fileLines = [];
2422
+ try {
2423
+ // Non-UTF-8 targets (UTF-16 BOM / binary) cannot round-trip through
2424
+ // decoded `-` lines — the native byte compare rejects the hunk and the
2425
+ // file becomes UNDELETABLE through apply_patch. A delete needs no
2426
+ // content match; emit the header-only form (already the unreadable-
2427
+ // file shape below) and let the engine remove the file by intent.
2428
+ const _delRaw = readFileSync(fullPath);
2429
+ // decodeValidUtf8OrNull (fatal TextDecoder) instead of Buffer.isUtf8:
2430
+ // the daemon may run under a runtime where Buffer.isUtf8 is absent,
2431
+ // and a missing-API fallback of "assume UTF-8" silently re-enables
2432
+ // the content hunks this gate exists to suppress.
2433
+ if (decodeValidUtf8OrNull(_delRaw) !== null) {
2434
+ fileLines = v4aConversionSourceLines(fullPath, v4aLinesCache);
2435
+ }
2436
+ } catch {
2437
+ fileLines = [];
2438
+ }
2439
+ out.push(`--- a/${displayPath}`);
2440
+ out.push('+++ /dev/null');
2441
+ if (fileLines.length > 0) {
2442
+ out.push(`@@ -1,${fileLines.length} +0,0 @@`);
2443
+ for (const line of fileLines) out.push(`-${line}`);
2444
+ }
2445
+ continue;
2446
+ }
2447
+
2448
+ const fullPath = resolveV4AEntryPath(basePath, section.path);
2449
+ let sourceLines;
2450
+ try {
2451
+ sourceLines = v4aConversionSourceLines(fullPath, v4aLinesCache);
2452
+ } catch (err) {
2453
+ throw new Error(`V4A update target unreadable: ${section.path} (${err?.code || err?.message || String(err)}).`);
2454
+ }
2455
+ const sectionHunks = [];
2456
+ const orderedHunks = orderV4AHunksByFilePosition(sourceLines, section.hunks, fuzzy);
2457
+ let nextSearchLine = 0;
2458
+ for (const hunk of orderedHunks) {
2459
+ const stats = v4AHunkLineStats(hunk);
2460
+ if (stats.oldCount === 0 && stats.newCount === 0) continue;
2461
+ const loc = resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, { fuzzy });
2462
+ if (loc.skip) continue;
2463
+ if (loc.error) {
2464
+ const msg = `${loc.error.replace(/^V4A hunk /, `V4A hunk ${section.path}: `)}`;
2465
+ if (rejectPartial) throw new Error(msg);
2466
+ if (rejectedHunks) rejectedHunks.push({ file: section.path, hunk, reason: msg });
2467
+ continue;
2468
+ }
2469
+ const oldStart = stats.oldCount === 0 ? loc.oldStartIdx : loc.oldStartIdx + 1;
2470
+ const newStart = oldStart;
2471
+ const tail = (hunk.anchors || []).filter(Boolean).join(' ');
2472
+ const oldCount = stats.oldCount === 0 ? 0 : loc.matchLen;
2473
+ const newCount = stats.newCount - (loc.trimmedTrailingNew || 0);
2474
+ sectionHunks.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${tail ? ` ${tail}` : ''}`);
2475
+ // EOF-trim: resolveV4AHunkPosition dropped the trailing empty line from
2476
+ // oldLinesPattern (and optionally newLinesPattern). Drop the matching
2477
+ // trailing body line(s) so old/new body counts equal the header counts.
2478
+ let dropOldAt = -1;
2479
+ let dropNewAt = -1;
2480
+ if (loc.trimmedTrailing) {
2481
+ for (let i = hunk.lines.length - 1; i >= 0; i--) {
2482
+ const ln = hunk.lines[i];
2483
+ if (isV4AEndOfFileMarker(ln)) continue;
2484
+ const p = ln[0];
2485
+ if (dropOldAt < 0 && (p === ' ' || p === '-')) dropOldAt = i;
2486
+ if (dropNewAt < 0 && loc.trimmedTrailingNew && (p === ' ' || p === '+')) dropNewAt = i;
2487
+ if (dropOldAt >= 0 && (!loc.trimmedTrailingNew || dropNewAt >= 0)) break;
2488
+ }
2489
+ }
2490
+ let srcIdx = loc.oldStartIdx;
2491
+ const srcEnd = loc.oldStartIdx + loc.matchLen;
2492
+ for (let i = 0; i < hunk.lines.length; i++) {
2493
+ const line = hunk.lines[i];
2494
+ if (isV4AEndOfFileMarker(line)) continue;
2495
+ const prefix = line[0];
2496
+ if (prefix === ' ' || prefix === '-') {
2497
+ if (i === dropOldAt || i === dropNewAt) continue;
2498
+ if (srcIdx < srcEnd && srcIdx < sourceLines.length) {
2499
+ sectionHunks.push(prefix + sourceLines[srcIdx]);
2500
+ } else {
2501
+ sectionHunks.push(line);
2502
+ }
2503
+ srcIdx++;
2504
+ } else {
2505
+ if (i === dropNewAt) continue;
2506
+ sectionHunks.push(line);
2507
+ }
2508
+ }
2509
+ nextSearchLine = loc.nextSearchLine;
2510
+ }
2511
+ if (sectionHunks.length > 0) {
2512
+ out.push(`--- a/${displayPath}`);
2513
+ out.push(`+++ b/${displayPath}`);
2514
+ for (const line of sectionHunks) out.push(line);
2515
+ }
2516
+ }
2517
+ return out.join('\n') + '\n';
2518
+ }
2519
+
2520
+ // Native-only apply_patch entry point.
2521
+ // - Pre-validates security (path-escape, symlink-escape, duplicates).
2522
+ // - V4A / unified-bare V4A inputs are converted to standard unified first.
2523
+ // - parsePatch errors / unsupported headers / missing binary throw clean
2524
+ // Error strings — they DO NOT fall back to a JS engine.
2525
+ // - Native engine handles fuzz / reject_partial / hunkless-delete /
2526
+ // zero-length-delete entirely. fuzzy:false → fuzz 0 (strict), else 2.
2527
+ // - On OK the response includes a per-entry success line + native trace.
2528
+ // - On OK_PARTIAL the response prefix is "Error: patch partially applied"
2529
+ // and per-entry SKIP lines surface the Rust failure reasons.
2530
+ // Some providers (notably grok-composer) serialize a multi-line V4A `patch`
2531
+ // argument as a flat key:value object: the `*** Update File: <path>` header's
2532
+ // colon-space plus the newlines make the tool-arg decoder split each patch line
2533
+ // into alternating keys/values, so `patch` arrives as just "*** Begin Patch"
2534
+ // and the real body leaks into stray top-level keys. Rebuild the original by
2535
+ // re-joining the patch value with the stray entries in insertion order; the V4A
2536
+ // parser + native engine then apply it normally. The trigger is tight (an
2537
+ // incomplete `*** Begin Patch` opener with no newline, plus keys outside the
2538
+ // schema) so well-formed calls are untouched. Keys on the shape, not the model.
2539
+ const APPLY_PATCH_SCHEMA_KEYS = new Set(['patch', 'format', 'base_path', 'dry_run', 'reject_partial', 'fuzzy']);
2540
+ function salvageShatteredV4APatchArgs(args) {
2541
+ if (!args || typeof args !== 'object') return args;
2542
+ const rawPatch = typeof args.patch === 'string' ? args.patch : '';
2543
+ if (!rawPatch.startsWith('*** Begin Patch') || rawPatch.includes('\n') || rawPatch.includes('*** End Patch')) return args;
2544
+ const stray = Object.keys(args).filter((k) => !APPLY_PATCH_SCHEMA_KEYS.has(k));
2545
+ if (stray.length === 0) return args;
2546
+ const lines = [rawPatch];
2547
+ for (const key of Object.keys(args)) {
2548
+ if (APPLY_PATCH_SCHEMA_KEYS.has(key)) continue;
2549
+ lines.push(key);
2550
+ lines.push(String(args[key] ?? ''));
2551
+ }
2552
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
2553
+ const cleaned = {};
2554
+ for (const key of Object.keys(args)) if (APPLY_PATCH_SCHEMA_KEYS.has(key)) cleaned[key] = args[key];
2555
+ cleaned.patch = lines.join('\n');
2556
+ return cleaned;
2557
+ }
2558
+ async function apply_patch(args, cwd, options = {}) {
2559
+ args = salvageShatteredV4APatchArgs(args);
2560
+ // Strip a leading UTF-8 BOM up-front: editors / PowerShell redirections
2561
+ // sometimes prepend `\uFEFF` to text files and the bare BOM trips the
2562
+ // unified envelope check.
2563
+ const patchStr = (typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, '');
2564
+ if (!patchStr.trim()) {
2565
+ throw new Error('apply_patch: "patch" is required (unified diff or V4A patch string)');
2566
+ }
2567
+ const requestedFormat = String(args?.format || '').toLowerCase();
2568
+ if (requestedFormat && requestedFormat !== 'unified' && requestedFormat !== 'v4a') {
2569
+ throw new Error('apply_patch: "format" must be "unified" or "v4a"');
2570
+ }
2571
+ let mutationPlan = options?.mutationPlan || planApplyPatchMutationRoute(args, patchStr, requestedFormat);
2572
+ const readStateScope = options?.readStateScope ?? options?.sessionId ?? null;
2573
+ let abortSignal = options?.signal || options?.abortSignal || null;
2574
+ if (!abortSignal && options?.sessionId) {
2575
+ try { abortSignal = await getAbortSignalForSession(options.sessionId); } catch { abortSignal = null; }
2576
+ }
2577
+ if (abortSignal?.aborted) {
2578
+ throw new Error(abortSignal.reason?.message || abortSignal.reason || 'apply_patch aborted');
2579
+ }
2580
+ const basePath = resolveBasePath(cwd, args?.base_path);
2581
+ try {
2582
+ await assertPathReachable(basePath);
2583
+ } catch (err) {
2584
+ return `Error: ${err?.message || String(err)}`;
2585
+ }
2586
+ // Default true — file-batch atomic. reject_partial:false unlocks the
2587
+ // native engine's OK_PARTIAL isolation mode.
2588
+ const rejectPartial = args?.reject_partial !== false;
2589
+ const dryRun = args?.dry_run === true;
2590
+ const fuzzy = args?.fuzzy !== false;
2591
+ // fuzzy:false → strict context match (fuzz 0); else allow 2 lines of
2592
+ // outer-context drift and ignore context trailing spaces/tabs. The same
2593
+ // fuzz value is forwarded to the V4A line-sequence search so both layers agree.
2594
+ const fuzz = fuzzy ? 2 : 0;
2595
+
2596
+ // V4A → unified conversion (in JS). Hunk anchor/context miss in the
2597
+ // conversion stage surfaces a clean Error — no JS apply fallback.
2598
+ let inputPatchStr = patchStr;
2599
+ const rejectedV4AHunks = [];
2600
+ const v4aConvertOpts = { rejectPartial, rejectedHunks: rejectedV4AHunks, fuzzy, dryRun, readStateScope };
2601
+ let v4aRenamePlan = null;
2602
+ if (isV4APatchInput(patchStr, requestedFormat)) {
2603
+ try {
2604
+ const allSections = parseV4APatch(patchStr);
2605
+ v4aRenamePlan = await planV4ARenameSections(allSections, basePath);
2606
+ inputPatchStr = await convertV4ASectionsToUnifiedPatch(v4aRenamePlan.remainingSections, basePath, v4aConvertOpts);
2607
+ if (v4aRenamePlan.renameSections.length > 0) {
2608
+ mutationPlan = v4aRenamePlan.remainingSections.length > 0
2609
+ ? { sourceTool: 'apply_patch', engine: 'v4a-patch', reason: 'v4a-mixed' }
2610
+ : { sourceTool: 'apply_patch', engine: 'v4a-rename', reason: 'v4a-move' };
2611
+ }
2612
+ } catch (err) {
2613
+ throw new Error(`apply_patch: V4A parse failed — ${err?.message || String(err)}`);
2614
+ }
2615
+ } else if (requestedFormat !== 'unified' && hasUnifiedBareV4AHunk(patchStr)) {
2616
+ try {
2617
+ inputPatchStr = await convertUnifiedBareV4AToUnifiedPatch(patchStr, basePath, v4aConvertOpts);
2618
+ } catch (err) {
2619
+ throw new Error(`apply_patch: bare @@ parse failed — ${err?.message || String(err)}`);
2620
+ }
2621
+ }
2622
+ let normalizedPatchStr = prepareInput(inputPatchStr);
2623
+ const v4aRenameOnly = v4aRenamePlan?.renameSections?.length > 0 && v4aRenamePlan.remainingSections.length === 0;
2624
+
2625
+ // parsePatch remains strict. In auto mode only, counted unified diffs
2626
+ // with bad @@ counts can be reinterpreted through the V4A converter so
2627
+ // exact old/context lines are still verified before native apply.
2628
+ let parsed = [];
2629
+ if (!v4aRenameOnly) try {
2630
+ parsed = parsePatch(normalizedPatchStr);
2631
+ } catch (err) {
2632
+ if (!canFallbackCountedUnified(patchStr, requestedFormat, err)) {
2633
+ throw new Error(`apply_patch: parse failed — ${err?.message || String(err)}; prefer Codex/V4A envelope for multi-hunk edits (no @@ line counts)`);
2634
+ }
2635
+ try {
2636
+ inputPatchStr = await convertUnifiedCountedToUnifiedPatchViaV4A(patchStr, basePath, v4aConvertOpts);
2637
+ normalizedPatchStr = prepareInput(inputPatchStr);
2638
+ parsed = parsePatch(normalizedPatchStr);
2639
+ mutationPlan = {
2640
+ sourceTool: 'apply_patch',
2641
+ engine: 'v4a-patch',
2642
+ reason: 'unified-count-fallback',
2643
+ };
2644
+ } catch (fallbackErr) {
2645
+ throw new Error(`apply_patch: parse failed — ${err?.message || String(err)}; V4A fallback failed — ${fallbackErr?.message || String(fallbackErr)}`);
2646
+ }
2647
+ }
2648
+ if (!v4aRenameOnly && (!Array.isArray(parsed) || parsed.length === 0)) {
2649
+ return 'Error: patch contained no file sections';
2650
+ }
2651
+
2652
+ // Pre-validate paths / duplicates / symlink escapes — throws on any
2653
+ // unsupported entry. Throws bubble out to the tool dispatcher as clean
2654
+ // "Error: ..." strings.
2655
+ if (!v4aRenameOnly) {
2656
+ try {
2657
+ await ensureNativePatchBinaryAvailable();
2658
+ } catch (err) {
2659
+ return `Error: ${err?.message || String(err)}`;
2660
+ }
2661
+ }
2662
+ let entries = [];
2663
+ let headerRewrites = [];
2664
+ if (!v4aRenameOnly) {
2665
+ try {
2666
+ ({ entries, headerRewrites } = await preValidateNativeBatch(parsed, basePath));
2667
+ } catch (err) {
2668
+ return `Error: ${err?.message || String(err)}`;
2669
+ }
2670
+ }
2671
+
2672
+ const _lockPaths = [
2673
+ ...entries.map((entry) => entry.fullPath),
2674
+ ...(v4aRenamePlan?.renameSections || []).flatMap((section) => {
2675
+ const src = resolveV4AEntryPath(basePath, section.path);
2676
+ const dest = resolveV4AEntryPath(basePath, section.movePath);
2677
+ return [src, dest];
2678
+ }),
2679
+ ];
2680
+
2681
+ return withBuiltinPathLocks(_lockPaths, () =>
2682
+ withAdvisoryLocks(_lockPaths, async () => {
2683
+ let v4aRenameResults = [];
2684
+ if (v4aRenamePlan?.renameSections?.length) {
2685
+ v4aRenameResults = await applyV4ARenameSections(v4aRenamePlan.renameSections, basePath, v4aConvertOpts);
2686
+ }
2687
+ if (v4aRenameOnly) {
2688
+ const lines = formatV4ARenameSuccessLines(v4aRenameResults);
2689
+ if (lines.length === 0) return 'Error: patch contained no applicable file sections';
2690
+ return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { backend: 'v4a-rename' });
2691
+ }
2692
+ const nativePatchStr = rewriteHeaderPaths(normalizedPatchStr, headerRewrites);
2693
+ const nativeResult = await dispatchNativePatch({
2694
+ entries,
2695
+ basePath,
2696
+ nativePatchStr,
2697
+ fuzz,
2698
+ rejectPartial,
2699
+ dryRun,
2700
+ readStateScope,
2701
+ signal: abortSignal,
2702
+ parsed,
2703
+ });
2704
+ // V4A conversion may have isolated some hunks (rejectPartial:false).
2705
+ // Surface them as additional REJECT lines so callers see every dropped
2706
+ // change, native or JS-side.
2707
+ let combined = nativeResult;
2708
+ const renameLines = formatV4ARenameSuccessLines(v4aRenameResults);
2709
+ if (renameLines.length > 0 && !isPatchErrorText(nativeResult)) {
2710
+ combined = `${renameLines.join('\n')}\n${nativeResult}`;
2711
+ }
2712
+ if (!isPatchErrorText(combined) && rejectedV4AHunks.length > 0) {
2713
+ const tail = [
2714
+ '',
2715
+ `hunk-level rejected (rejectPartial=false, V4A): ${rejectedV4AHunks.length}`,
2716
+ ...rejectedV4AHunks.map((r) => ` REJECT ${r.file || '(unknown)'} — ${String(r.reason || '').split(';')[0].trim()}`),
2717
+ ];
2718
+ return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { backend: 'native-patch' });
2719
+ }
2720
+ return wrapPatchMutationOutput(combined, mutationPlan, { backend: 'native-patch' });
2721
+ }));
2722
+ }
2723
+
2724
+ // Test-only export: lets the regression harness exercise the interior-vs-outer
2725
+ // change-band logic in findFirstFailingUnifiedHunk without spawning the native
2726
+ // binary.
2727
+ export const __patchTestHooks = { findFirstFailingUnifiedHunk, computeUnifiedChangeBand, collectUnifiedOps, unifiedOldLinesMatchAt, splitBufferLinesForPatch };
2728
+
2729
+ export async function executePatchTool(name, args, cwd, options = {}) {
2730
+ const effectiveCwd = cwd || process.cwd();
2731
+ switch (name) {
2732
+ case 'apply_patch': {
2733
+ const result = await apply_patch(args || {}, effectiveCwd, options);
2734
+ // ② completion progress (claude "Found N" parity). Best-effort, no-op
2735
+ // when onProgress is absent (no progressToken). Never throws — only
2736
+ // emits on success (an "Error:" body is left to the tool result alone).
2737
+ if (typeof options?.onProgress === 'function') {
2738
+ try {
2739
+ const _body = String(result);
2740
+ if (!/^Error[\s:[]/.test(_body)) {
2741
+ if (args?.dry_run === true) options.onProgress('validated');
2742
+ else {
2743
+ const _m = /^(?:applied|checked)\s+(\d+)\b/m.exec(_body);
2744
+ const _n = _m ? Number(_m[1]) : (_body.match(/^\s*OK\s/gm) || []).length;
2745
+ options.onProgress(`applied ${_n} files`);
2746
+ }
2747
+ }
2748
+ } catch { /* best-effort */ }
2749
+ }
2750
+ return result;
2751
+ }
2752
+ default: throw new Error(`Unknown patch tool: ${name}`);
2753
+ }
2754
+ }