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,1364 @@
1
+ import { readFileSync, statSync } from 'fs';
2
+ import * as fsPromises from 'fs/promises';
3
+ import { performance } from 'perf_hooks';
4
+ import { markCodeGraphDirtyPaths } from '../code-graph.mjs';
5
+ import {
6
+ diagnoseFoldTierAmbiguity as _diagnoseFoldTierAmbiguity,
7
+ findActualString as _findActualString,
8
+ stripTrailingWhitespacePerLine as _stripTrailingWhitespacePerLine,
9
+ stripTrailingWhitespaceForEdit as _stripTrailingWhitespaceForEdit,
10
+ preserveQuoteTypography as _preserveQuoteTypography,
11
+ formatStageInline as _formatStageInline,
12
+ formatStageNote as _formatStageNote,
13
+ } from '../edit-normalize.mjs';
14
+ import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
15
+ import { createMutationContentCache, isValidUtf8Buffer as _isValidUtf8Buffer } from '../mutation-content-cache.mjs';
16
+ import {
17
+ normalizeOutputPath,
18
+ resolveAgainstCwd,
19
+ } from './path-utils.mjs';
20
+ import {
21
+ findSimilarFile,
22
+ normalizeErrorMessage,
23
+ } from './path-diagnostics.mjs';
24
+ import { normalizePathAndStripLineCoordinate } from './read-args.mjs';
25
+ import {
26
+ withBuiltinPathLocks,
27
+ withPathLock as _withPathLock,
28
+ } from './path-locks.mjs';
29
+ import { withAdvisoryLocks } from './advisory-lock.mjs';
30
+ import { hashText as _hashText } from './hash-utils.mjs';
31
+ import { statMatchesSnapshot as _statMatchesSnapshot } from './snapshot-helpers.mjs';
32
+ import {
33
+ invalidateBuiltinResultCache,
34
+ getPathMutationGeneration as _getPathMutationGeneration,
35
+ rawContentCacheGet as _rawContentCacheGet,
36
+ seedRawContentCacheAfterWrite as _seedRawContentCacheAfterWrite,
37
+ } from './cache-layers.mjs';
38
+ import {
39
+ getReadSnapshot as _getReadSnapshot,
40
+ isSnapshotStale as _isSnapshotStale,
41
+ readContentIfSnapshotHashMatches as _readContentIfSnapshotHashMatches,
42
+ recordReadSnapshot as _recordReadSnapshot,
43
+ } from './read-snapshot-runtime.mjs';
44
+ import {
45
+ captureStableBaseStatSnapshot as _captureStableBaseStatSnapshot,
46
+ captureExpectedTargetSnapshot as _captureExpectedTargetSnapshot,
47
+ materialiseByteReplacements as _materialiseByteReplacements,
48
+ } from './edit-byte-utils.mjs';
49
+ import {
50
+ nativeEditShouldAttempt as _nativeEditShouldAttempt,
51
+ runNativeExactEdit as _runNativeExactEdit,
52
+ } from './native-edit-runner.mjs';
53
+ import {
54
+ countLiteralOccurrences as _countLiteralOccurrences,
55
+ findCrlfNormalisedMatches as _findCrlfNormalisedMatches,
56
+ findLiteralOccurrenceState as _findLiteralOccurrenceState,
57
+ formatMatchLines as _formatMatchLines,
58
+ occurrenceLinesCrlf as _occurrenceLinesCrlf,
59
+ occurrenceLinesPlain as _occurrenceLinesPlain,
60
+ replacementForOriginalSlice as _replacementForOriginalSlice,
61
+ replaceRangesFromOriginal as _replaceRangesFromOriginal,
62
+ replaceSingleLiteralAt as _replaceSingleLiteralAt,
63
+ validateEditChunkSize as _validateEditChunkSize,
64
+ } from './edit-match-utils.mjs';
65
+ import {
66
+ diagnoseBatchPeers as _diagnoseBatchPeers,
67
+ editNeedleEncodingNote as _editNeedleEncodingNote,
68
+ } from './edit-diagnostics.mjs';
69
+ import {
70
+ countLfInString as _countLfInString,
71
+ maybeAutoStripLineNumberPrefixes as _maybeAutoStripLineNumberPrefixes,
72
+ postEditSnapshotMeta as _postEditSnapshotMeta,
73
+ shiftSnapshotRangesForEdit as _shiftSnapshotRangesForEdit,
74
+ lineRangeForSubstring as _lineRangeForSubstring,
75
+ } from './edit-context-utils.mjs';
76
+ import {
77
+ tryBuildExactEditBuffer as _tryBuildExactEditBufferImpl,
78
+ tryBuildMultiExactEditBuffer as _tryBuildMultiExactEditBufferImpl,
79
+ } from './edit-byte-plan.mjs';
80
+ import { tryWriteSameSizeByteReplacementsSync as _tryWriteSameSizeByteReplacementsSyncImpl } from './edit-partial-write.mjs';
81
+ import {
82
+ buildStaleEditRecovery as _buildStaleEditRecovery,
83
+ editFailureContextHint as _editFailureContextHint,
84
+ primeReadSnapshotForEdit as _primeReadSnapshotForEdit,
85
+ } from './edit-failure-context.mjs';
86
+ import { validatePreparedEditBase as _validatePreparedEditBase } from './edit-base-guard.mjs';
87
+ import {
88
+ commitPreparedEditCheckedUnlocked as _commitPreparedEditCheckedUnlockedImpl,
89
+ commitPreparedEditUnlocked as _commitPreparedEditUnlockedImpl,
90
+ } from './edit-commit.mjs';
91
+ import { atomicWrite } from './atomic-write.mjs';
92
+ import {
93
+ hasUnsafeWin32Component,
94
+ isWindowsDevicePath,
95
+ } from './device-paths.mjs';
96
+ import { assertEditTargetUtf8 as _assertEditTargetUtf8 } from './edit-utf8-guard.mjs';
97
+ import { attemptStaleEditAutoRefresh as _attemptStaleEditAutoRefresh } from './edit-stale-refresh.mjs';
98
+
99
+ function _optionalEditMissDetails(content, oldString) {
100
+ return _editNeedleEncodingNote(content, oldString);
101
+ }
102
+
103
+ function _foldTierAmbiguityError(content, oldString, filePath, editPrefix = '', peerArgs = null) {
104
+ const amb = _diagnoseFoldTierAmbiguity(content, oldString);
105
+ if (!amb || amb.count <= 1) return null;
106
+ const stageNote = _formatStageInline(amb.stage);
107
+ return `Error [code 9]: ${editPrefix}old_string found ${amb.count} times in ${filePath}${stageNote};${_formatMatchLines(amb.lines, amb.count)} set replace_all:true or provide more unique context${peerArgs ? _diagnoseBatchPeers(...peerArgs) : ''}`;
108
+ }
109
+
110
+ function _ioTraceEnabled() {
111
+ return /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_IO_TRACE || ''));
112
+ }
113
+
114
+ function _ioTraceStart() {
115
+ return _ioTraceEnabled() ? performance.now() : 0;
116
+ }
117
+
118
+ function _ioTrace(event, fields = {}) {
119
+ if (!_ioTraceEnabled()) return;
120
+ try {
121
+ process.stderr.write(`[io-trace] ${JSON.stringify({
122
+ event,
123
+ ts: Date.now(),
124
+ ...fields,
125
+ })}\n`);
126
+ } catch {}
127
+ }
128
+
129
+ function _ioTraceDone(event, started, fields = {}) {
130
+ if (!started || !_ioTraceEnabled()) return;
131
+ _ioTrace(event, {
132
+ ...fields,
133
+ ms: Number((performance.now() - started).toFixed(3)),
134
+ });
135
+ }
136
+
137
+ function _editTraceEnabled() {
138
+ return _ioTraceEnabled() || /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_EDIT_TRACE || ''));
139
+ }
140
+
141
+ function _editTrace(event, fields = {}) {
142
+ if (!_editTraceEnabled()) return;
143
+ try {
144
+ process.stderr.write(`[edit-trace] ${JSON.stringify({
145
+ event,
146
+ ts: Date.now(),
147
+ ...fields,
148
+ })}\n`);
149
+ } catch {}
150
+ }
151
+
152
+ function _editPathTrace(event, filePath, fields = {}) {
153
+ if (!_editTraceEnabled()) return;
154
+ _editTrace(event, {
155
+ path: normalizeOutputPath(filePath),
156
+ ...fields,
157
+ });
158
+ }
159
+
160
+ function _loadEditTargetBytes(fullPath) {
161
+ try {
162
+ const rawBuf = readFileSync(fullPath);
163
+ if (!Buffer.isBuffer(rawBuf)) return null;
164
+ return { rawBuf, content: rawBuf.toString('utf-8') };
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /** Authoritative target bytes while the path lock is held (cold-path TOCTOU guard). */
171
+ function _readEditTargetBytesUnderLock(fullPath, filePath, traceReason = null, mode = 'single') {
172
+ const loaded = _loadEditTargetBytes(fullPath);
173
+ if (!loaded) return null;
174
+ if (traceReason) {
175
+ _editPathTrace('edit_lock_cold_reread', filePath, { mode, reason: traceReason });
176
+ }
177
+ return loaded;
178
+ }
179
+
180
+ function _tryStaleSnapshotAutoRefresh({
181
+ fullPath,
182
+ filePath,
183
+ scope,
184
+ stat,
185
+ readRanges,
186
+ oldStrings,
187
+ readCache,
188
+ recordPreviewSnapshot = false,
189
+ }) {
190
+ const refreshed = _attemptStaleEditAutoRefresh({
191
+ fullPath,
192
+ filePath,
193
+ scope,
194
+ stat,
195
+ readRanges,
196
+ oldStrings,
197
+ readCache,
198
+ recordPreviewSnapshot,
199
+ });
200
+ if (!refreshed) return null;
201
+ if (refreshed.ok === false && typeof refreshed.error === 'string') {
202
+ return { error: refreshed.error };
203
+ }
204
+ if (refreshed.ok === true && typeof refreshed.content === 'string' && Buffer.isBuffer(refreshed.rawBuf)) {
205
+ return { content: refreshed.content, rawBuf: refreshed.rawBuf };
206
+ }
207
+ return null;
208
+ }
209
+
210
+ function _tryBuildExactEditBuffer(rawBuf, oldStr, newStr, replaceAll, snapshot, filePath) {
211
+ return _tryBuildExactEditBufferImpl(rawBuf, oldStr, newStr, replaceAll, snapshot, filePath);
212
+ }
213
+
214
+ function _tryBuildMultiExactEditBuffer(rawBuf, edits, args, snapshot, filePath) {
215
+ return _tryBuildMultiExactEditBufferImpl(rawBuf, edits, args, snapshot, filePath);
216
+ }
217
+
218
+ // Edit input normalization helpers (_normalizeForMatch, _nfcFoldMatch,
219
+ // _findActualString, _stripTrailingWhitespacePerLine, _formatStageInline,
220
+ // _formatStageNote) extracted to ./edit-normalize.mjs — see import at top
221
+ // of file. Pipeline: byte-exact → exact → curly-quote fold → nfc-fold →
222
+ // rstrip-fold → indent-fold → eol-fold (edit-normalize; last-resort + code 9)
223
+ // → crlf-fold (engine slow-path).
224
+
225
+ const _partialWriteHooks = {
226
+ ioTraceStart: _ioTraceStart,
227
+ ioTraceDone: _ioTraceDone,
228
+ validatePreparedEditBase: (...args) => _validatePreparedEditBase(...args),
229
+ };
230
+
231
+ function _tryWriteSameSizeByteReplacementsSync(fullPath, replacements, options = {}) {
232
+ return _tryWriteSameSizeByteReplacementsSyncImpl(fullPath, replacements, options, _partialWriteHooks);
233
+ }
234
+
235
+ async function _prepareMultiEdit(args, workDir, readStateScope, _pathOpts, options = {}) {
236
+ args.path = normalizePathAndStripLineCoordinate(args.path, workDir);
237
+ const filePath = args.path;
238
+ const edits = Array.isArray(args.edits) ? args.edits : [];
239
+ if (!filePath) return { ok: false, error: 'Error: path is required' };
240
+ if (edits.length === 0) return { ok: false, error: 'Error: edits array is required' };
241
+ // R12: Win32 component guard — reject trailing dot/space or NTFS ADS
242
+ // suffix (foo.txt:ads) and reserved device names (NUL, CON, …) before
243
+ // resolve so a relative path can't be coerced into a device alias.
244
+ if (typeof isWindowsDevicePath === 'function' && isWindowsDevicePath(filePath)) {
245
+ return { ok: false, error: `Error: cannot edit Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(filePath)}` };
246
+ }
247
+ if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(filePath)) {
248
+ return { ok: false, error: `Error: cannot edit Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(filePath)}` };
249
+ }
250
+ const fullPath = resolveAgainstCwd(filePath, workDir);
251
+ // R1: short-circuit UNC/SMB paths before ANY stat/read on the edit
252
+ // target to prevent NTLM credential leakage via implicit network
253
+ // auth. Mirrors CC FileEditTool.ts:176.
254
+ if (fullPath.startsWith('\\\\') || fullPath.startsWith('//')) {
255
+ return { ok: false, error: `Error: UNC/SMB paths are not supported (R1: NTLM-leak prevention): ${filePath}` };
256
+ }
257
+ if (typeof isWindowsDevicePath === 'function' && isWindowsDevicePath(fullPath)) {
258
+ return { ok: false, error: `Error: cannot edit Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(filePath)}` };
259
+ }
260
+ if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(fullPath)) {
261
+ return { ok: false, error: `Error: cannot edit Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(filePath)}` };
262
+ }
263
+ let mEditStat;
264
+ try { mEditStat = statSync(fullPath); }
265
+ catch (err) {
266
+ if (err && err.code === 'ENOENT') {
267
+ const similar = findSimilarFile(fullPath);
268
+ const hint = similar ? ` Did you mean "${normalizeOutputPath(similar)}"?` : '';
269
+ return { ok: false, error: `Error [code 4]: file not found: ${filePath}${hint}` };
270
+ }
271
+ return { ok: false, error: `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}` };
272
+ }
273
+ if (mEditStat.size > 1073741824) {
274
+ return { ok: false, error: `Error: edit refused: file too large (size: ${mEditStat.size}B, cap: 1GiB)` };
275
+ }
276
+ let mEditPreloadedContent = null;
277
+ let mEditPreloadedRawBuf = null;
278
+ let mEditSnapshot = _getReadSnapshot(fullPath, readStateScope);
279
+ if (!mEditSnapshot) {
280
+ const _mPrimed = _primeReadSnapshotForEdit({
281
+ fullPath,
282
+ filePath,
283
+ st: mEditStat,
284
+ scope: readStateScope,
285
+ oldStrings: [],
286
+ });
287
+ mEditSnapshot = _getReadSnapshot(fullPath, readStateScope);
288
+ if (_mPrimed) {
289
+ _editPathTrace('edit_auto_snapshot', filePath, { mode: 'multi' });
290
+ if (typeof _mPrimed.content === 'string' && Buffer.isBuffer(_mPrimed.rawBuf)) {
291
+ mEditPreloadedContent = _mPrimed.content;
292
+ mEditPreloadedRawBuf = _mPrimed.rawBuf;
293
+ }
294
+ }
295
+ }
296
+ const mEditSnapshotReadCache = createMutationContentCache();
297
+ if (mEditSnapshot && _isSnapshotStale(mEditStat, mEditSnapshot, fullPath, mEditSnapshotReadCache)) {
298
+ mEditPreloadedContent = _readContentIfSnapshotHashMatches(fullPath, mEditSnapshot, mEditSnapshotReadCache, mEditStat);
299
+ if (mEditPreloadedContent !== null) {
300
+ const cached = mEditSnapshotReadCache.getEntry(fullPath);
301
+ if (Buffer.isBuffer(cached?.rawBuf)) mEditPreloadedRawBuf = cached.rawBuf;
302
+ }
303
+ if (mEditPreloadedContent === null) {
304
+ const _staleRefresh = _tryStaleSnapshotAutoRefresh({
305
+ fullPath,
306
+ filePath,
307
+ scope: readStateScope,
308
+ stat: mEditStat,
309
+ readRanges: mEditSnapshot?.ranges,
310
+ oldStrings: edits,
311
+ readCache: mEditSnapshotReadCache,
312
+ recordPreviewSnapshot: false,
313
+ });
314
+ if (_staleRefresh?.error) return { ok: false, error: _staleRefresh.error };
315
+ if (_staleRefresh?.content) {
316
+ mEditPreloadedContent = _staleRefresh.content;
317
+ mEditPreloadedRawBuf = _staleRefresh.rawBuf;
318
+ mEditSnapshot = _getReadSnapshot(fullPath, readStateScope);
319
+ } else {
320
+ const recovery = _buildStaleEditRecovery({
321
+ fullPath,
322
+ scope: readStateScope,
323
+ oldStrings: edits,
324
+ recordPreviewSnapshot: false,
325
+ });
326
+ return { ok: false, error: `Error [code 7]: file modified since read (lint / formatter / external write) — read it again before editing: ${filePath}${recovery}` };
327
+ }
328
+ }
329
+ } else {
330
+ const cached = mEditSnapshotReadCache.getEntry(fullPath);
331
+ if (typeof cached?.content === 'string') {
332
+ mEditPreloadedContent = cached.content;
333
+ if (Buffer.isBuffer(cached.rawBuf)) mEditPreloadedRawBuf = cached.rawBuf;
334
+ }
335
+ }
336
+ try {
337
+ try {
338
+ mEditStat = statSync(fullPath);
339
+ } catch (err) {
340
+ if (err && err.code === 'ENOENT') {
341
+ const similar = findSimilarFile(fullPath);
342
+ const hint = similar ? ` Did you mean "${normalizeOutputPath(similar)}"?` : '';
343
+ return { ok: false, error: `Error [code 4]: file not found: ${filePath}${hint}` };
344
+ }
345
+ return { ok: false, error: `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}` };
346
+ }
347
+ if (!mEditSnapshot) {
348
+ const _cold = _readEditTargetBytesUnderLock(fullPath, filePath, 'no_snapshot', 'multi');
349
+ if (!_cold) {
350
+ return { ok: false, error: `Error: failed to read edit target: ${filePath}` };
351
+ }
352
+ mEditPreloadedContent = _cold.content;
353
+ mEditPreloadedRawBuf = _cold.rawBuf;
354
+ } else if (mEditPreloadedRawBuf !== null && mEditSnapshot
355
+ && typeof mEditSnapshot.contentHash === 'string') {
356
+ const _primed = _loadEditTargetBytes(fullPath);
357
+ if (_primed) {
358
+ if (_hashText(_primed.rawBuf) !== _hashText(mEditPreloadedRawBuf)) {
359
+ _editPathTrace('edit_lock_cold_reread', filePath, {
360
+ mode: 'multi',
361
+ reason: 'auto_snapshot_content_drift',
362
+ });
363
+ }
364
+ if (_hashText(_primed.rawBuf) === mEditSnapshot.contentHash) {
365
+ mEditPreloadedContent = _primed.content;
366
+ mEditPreloadedRawBuf = _primed.rawBuf;
367
+ } else {
368
+ mEditPreloadedContent = null;
369
+ mEditPreloadedRawBuf = null;
370
+ }
371
+ }
372
+ }
373
+ let rawContent = mEditPreloadedRawBuf;
374
+ try {
375
+ if (rawContent === null) {
376
+ const cachedRaw = _rawContentCacheGet(fullPath, mEditStat);
377
+ rawContent = cachedRaw
378
+ || (mEditPreloadedContent === null
379
+ ? readFileSync(fullPath)
380
+ : Buffer.from(mEditPreloadedContent, 'utf-8'));
381
+ }
382
+ }
383
+ catch (err) { return { ok: false, error: `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}` }; }
384
+ let baseStatSnapshot = null;
385
+ try {
386
+ const postReadStat = statSync(fullPath);
387
+ if (_statMatchesSnapshot(postReadStat, mEditStat) && postReadStat.size === rawContent.length) {
388
+ baseStatSnapshot = {
389
+ mtimeMs: postReadStat.mtimeMs,
390
+ ctimeMs: postReadStat.ctimeMs,
391
+ size: postReadStat.size,
392
+ ino: Number(postReadStat.ino),
393
+ };
394
+ }
395
+ } catch { /* fall back to hash compare in _validatePreparedEditBase */ }
396
+ const baseContentHash = _hashText(rawContent);
397
+ const expectedTargetSnapshot = _captureExpectedTargetSnapshot(fullPath, mEditStat);
398
+ // Reviewer issue #3: validate encoding before the multi-edit
399
+ // byte-exact buffer build too. Previously this branch returned a
400
+ // prepared edit (and reached commit) before reaching the
401
+ // legacy `_isValidUtf8Buffer` check further below, so a Shift-JIS
402
+ // / Latin-1 / binary file could be mutated on the multi byte-exact
403
+ // path.
404
+ {
405
+ const _utf8Err = _assertEditTargetUtf8(rawContent, filePath);
406
+ if (_utf8Err) return { ok: false, error: _utf8Err };
407
+ }
408
+ const exactBufferEdit = _tryBuildMultiExactEditBuffer(rawContent, edits, args, mEditSnapshot, filePath);
409
+ if (exactBufferEdit?.error) return { ok: false, error: exactBufferEdit.error };
410
+ if (Buffer.isBuffer(exactBufferEdit?.updated) || (exactBufferEdit?.sameSize && Array.isArray(exactBufferEdit.replacements))) {
411
+ return {
412
+ ok: true,
413
+ filePath,
414
+ fullPath,
415
+ edits,
416
+ snapshot: mEditSnapshot,
417
+ content: exactBufferEdit.updated || null,
418
+ baseRawContent: rawContent,
419
+ sameSizeByteReplacements: exactBufferEdit.sameSize ? exactBufferEdit.replacements : null,
420
+ contentHash: exactBufferEdit.contentHash || (Buffer.isBuffer(exactBufferEdit.updated) ? _hashText(exactBufferEdit.updated) : null),
421
+ baseContentHash,
422
+ baseMutationGeneration: _getPathMutationGeneration(fullPath),
423
+ baseStatSnapshot,
424
+ expectedTargetSnapshot,
425
+ baseMode: mEditStat.mode & 0o777,
426
+ stageCounts: {},
427
+ };
428
+ }
429
+ if (!_isValidUtf8Buffer(rawContent)) {
430
+ const _bomNote = (rawContent.length >= 2 && rawContent[0] === 0xFF && rawContent[1] === 0xFE)
431
+ ? 'file is UTF-16LE (BOM FF FE) — edit only supports UTF-8; use write (preserves UTF-16) or convert the file'
432
+ : (rawContent.length >= 2 && rawContent[0] === 0xFE && rawContent[1] === 0xFF)
433
+ ? 'file is UTF-16BE (BOM FE FF) — edit only supports UTF-8; convert the file first'
434
+ : 'file appears to be non-UTF-8 (Shift-JIS/Latin-1/binary mix)';
435
+ return { ok: false, error: `Error: ${_bomNote}. Edit aborted to prevent silent corruption. Path: ${filePath}` };
436
+ }
437
+ let content = mEditPreloadedContent;
438
+ if (content === null) content = rawContent.toString('utf-8');
439
+ // CC parity: when new_string is empty (pure deletion) and old_string
440
+ // is followed by a newline in the file, swallow that newline as part
441
+ // of the match so the deletion doesn't leave an empty line behind.
442
+ // Mirrors FileEditTool/utils.ts applyEditToFile's stripTrailingNewline
443
+ // branch. CRLF fallback path keeps current behaviour to avoid range
444
+ // arithmetic complications.
445
+ const _absorbTrailingNewline = (cur, oldStr, newStr, replaceAll) => {
446
+ if (newStr !== '' || oldStr.endsWith('\n')) return oldStr;
447
+ // For replace_all pure deletion, do NOT globally rewrite oldStr —
448
+ // mixed-suffix occurrences (some followed by \n, some bare / at
449
+ // EOF) need per-occurrence absorption handled by the replace_all
450
+ // branch below; a single global eOldStr would skip bare sites.
451
+ if (replaceAll) return oldStr;
452
+ if (cur.includes(oldStr + '\r\n')) return oldStr + '\r\n';
453
+ return cur.includes(oldStr + '\n') ? oldStr + '\n' : oldStr;
454
+ };
455
+ // Sequential apply tracks an accumulated line delta so partial-
456
+ // coverage windows shift with the bytes. Each edit's delta is
457
+ // (newline count in new_string − newline count in old_string),
458
+ // multiplied by the number of occurrences actually replaced.
459
+ let _rollingSnapshot = mEditSnapshot;
460
+ const _bumpRollingSnapshot = (beforeContent, needle, lineDelta, replaceAll) => {
461
+ if (!Number.isFinite(lineDelta) || lineDelta === 0 || typeof needle !== 'string') return;
462
+ const span = _lineRangeForSubstring(beforeContent, needle, { replaceAll: replaceAll === true });
463
+ if (!span) return;
464
+ _rollingSnapshot = _shiftSnapshotRangesForEdit(_rollingSnapshot, {
465
+ editStartLine: span.startLine,
466
+ editEndLine: span.endLine,
467
+ lineDelta,
468
+ });
469
+ };
470
+ // Stage stats surface fold / nfc-fold / crlf-fold counts up to
471
+ // the caller so the response can flag non-exact matches without
472
+ // touching the per-edit return shape.
473
+ const _stageCounts = {};
474
+ // Hoisted markdown predicate — same trailing-whitespace policy as
475
+ // the single-edit case, computed once per call.
476
+ const _IS_MD_PATH = /\.(?:md|mdx)$/i.test(filePath);
477
+ // Independence invariant (matches fast-path semantics in
478
+ // tryBuildMultiExactEditBuffer): reject batches where one edit's
479
+ // new_string contains another edit's old_string. The slow path
480
+ // applies sequentially against a mutating buffer, so a later
481
+ // edit's old_string could match bytes that an earlier edit's
482
+ // new_string just synthesised (or, conversely, an earlier edit
483
+ // could destroy a later edit's anchor). Surfacing the invariant
484
+ // up-front makes batches deterministic and matches the fast-path
485
+ // contract that callers already see.
486
+ if (edits.length > 1) {
487
+ for (let a = 0; a < edits.length; a++) {
488
+ const ea = edits[a];
489
+ if (!ea || typeof ea.old_string !== 'string' || typeof ea.new_string !== 'string') continue;
490
+ for (let b = 0; b < edits.length; b++) {
491
+ if (a === b) continue;
492
+ const eb = edits[b];
493
+ if (!eb || typeof eb.old_string !== 'string') continue;
494
+ if (eb.old_string.length === 0) continue;
495
+ if (ea.new_string.indexOf(eb.old_string) !== -1) {
496
+ return { ok: false, error: `Error [code 12]: edits are not independent — edit ${a}'s new_string contains edit ${b}'s old_string in ${filePath}; split into separate edit() calls or reorder so no later edit matches bytes produced by an earlier edit.` };
497
+ }
498
+ }
499
+ }
500
+ }
501
+ for (let i = 0; i < edits.length; i++) {
502
+ const _contentBeforeEdit = content;
503
+ const entry = edits[i];
504
+ if (!entry || typeof entry.old_string !== 'string' || typeof entry.new_string !== 'string') {
505
+ return { ok: false, error: `Error: edit ${i} must have old_string and new_string` };
506
+ }
507
+ {
508
+ const _nulIdx = entry.new_string.indexOf('\u0000');
509
+ if (_nulIdx !== -1) {
510
+ return { ok: false, error: `Error [code 11]: edit ${i} — new_string contains NUL byte (U+0000) at offset ${_nulIdx} — source text must not contain NUL: ${filePath}` };
511
+ }
512
+ }
513
+ let { old_string: _origOld, new_string, replace_all } = entry;
514
+ // Same line-prefix recovery as the single-edit case (see edit
515
+ // handler). Sequential apply still validates each item, so a
516
+ // bad strip simply fails further down with code 8 instead of
517
+ // here.
518
+ if (typeof _origOld === 'string' && /^\s*\d+[\t│→]/.test(_origOld)) {
519
+ const _stripped = _maybeAutoStripLineNumberPrefixes(_origOld);
520
+ if (_stripped !== null) {
521
+ _editPathTrace('edit_auto_strip_line_numbers', filePath, { mode: 'multi', index: i });
522
+ _origOld = _stripped;
523
+ } else {
524
+ return { ok: false, error: `Error: edit ${i} — old_string mixes Read line-number-prefixed lines ("<n>│…") with raw lines — strip the prefix from every line (or none) before Edit: ${filePath}` };
525
+ }
526
+ }
527
+ // Same trailing whitespace normalization as the single-edit
528
+ // case. Markdown (`.md` / `.mdx`) skips the strip because two
529
+ // trailing spaces is the hard-line-break syntax. `_IS_MD_PATH`
530
+ // is hoisted above the loop so per-edit cost is one regex hit.
531
+ if (!_IS_MD_PATH) {
532
+ const _strippedNew = _stripTrailingWhitespaceForEdit(new_string, _origOld);
533
+ if (_strippedNew !== new_string) {
534
+ _editPathTrace('edit_trim_trailing_ws', filePath, { mode: 'multi', index: i });
535
+ new_string = _strippedNew;
536
+ }
537
+ }
538
+ if (_origOld.length === 0) {
539
+ return { ok: false, error: `Error: edit ${i} — old_string must be non-empty` };
540
+ }
541
+ if (new_string === _origOld) {
542
+ return { ok: false, error: `Error: edit ${i} — new_string must differ from old_string` };
543
+ }
544
+ const _matchInfo = {};
545
+ const _origLiteralOccurrence = _findLiteralOccurrenceState(content, _origOld);
546
+ // Move size gate to the FOLD-FALLBACK path: exact-unique
547
+ // byte matches are safe at any size, so the >=30-line
548
+ // code-10 wording only fires when we are about to leave
549
+ // exact and try fold/fuzzy. A unique exact hit
550
+ // (count === 1, or replace_all) bypasses the gate; an
551
+ // ambiguous exact hit (count > 1 without replace_all)
552
+ // surfaces via the standard code-9 path below.
553
+ if (_origLiteralOccurrence.count === 0) {
554
+ const _sizeErr = _validateEditChunkSize(_origOld, replace_all === true, false);
555
+ if (_sizeErr) return { ok: false, error: _sizeErr.replace('Error [code 10]:', `Error [code 10]: edit ${i} —`) };
556
+ }
557
+ const _matchedOld = _origLiteralOccurrence.count > 0
558
+ ? _origOld
559
+ : (_findActualString(content, _origOld, _matchInfo) || _origOld);
560
+ if (_origLiteralOccurrence.count > 0) _matchInfo.stage = 'exact';
561
+ if (_matchInfo.stage && _matchInfo.stage !== 'exact') {
562
+ _stageCounts[_matchInfo.stage] = (_stageCounts[_matchInfo.stage] || 0) + 1;
563
+ }
564
+ // CC parity: typography preservation (see edit-normalize.mjs).
565
+ const _newAfterTypo = _preserveQuoteTypography(_origOld, _matchedOld, new_string);
566
+ if (_newAfterTypo !== new_string) {
567
+ _editPathTrace('edit_typography_preserve', filePath, { mode: 'multi', index: i });
568
+ new_string = _newAfterTypo;
569
+ }
570
+ const old_string = _absorbTrailingNewline(content, _matchedOld, new_string, replace_all === true);
571
+ const _shiftedSnapshot = _rollingSnapshot;
572
+ const _oldNL = (old_string.match(/\n/g) || []).length;
573
+ const _newNL = (new_string.match(/\n/g) || []).length;
574
+ const _perOccurrenceDelta = _newNL - _oldNL;
575
+ const _oldStringLiteralOccurrence = (old_string === _origOld && _origLiteralOccurrence.count > 0)
576
+ ? _origLiteralOccurrence
577
+ : _findLiteralOccurrenceState(content, old_string);
578
+ if (replace_all === true) {
579
+ let _occurrences = 0;
580
+ if (_oldStringLiteralOccurrence.count > 0) {
581
+ let _occIdx = 0;
582
+ while ((_occIdx = content.indexOf(old_string, _occIdx)) !== -1) {
583
+ _occurrences++;
584
+ _occIdx += old_string.length;
585
+ }
586
+ }
587
+ if (_occurrences > 0) {
588
+ const _indentFixedNewAll = new_string;
589
+ // Pure-deletion (new_string === '' with no trailing line
590
+ // terminator on old): absorb a trailing \r\n / \n / \r
591
+ // PER OCCURRENCE. Mixed-suffix sites (some followed by
592
+ // a newline, some bare / at EOF) must all be removed;
593
+ // a single global eOldStr rewrite would skip every bare
594
+ // occurrence.
595
+ if (new_string === '' && !old_string.endsWith('\n') && !old_string.endsWith('\r')) {
596
+ const _ranges = [];
597
+ let _absorbedNewlines = 0;
598
+ let _scan = 0;
599
+ while ((_scan = content.indexOf(old_string, _scan)) !== -1) {
600
+ let _end = _scan + old_string.length;
601
+ if (content[_end] === '\r' && content[_end + 1] === '\n') { _end += 2; _absorbedNewlines += 1; }
602
+ else if (content[_end] === '\n') { _end += 1; _absorbedNewlines += 1; }
603
+ else if (content[_end] === '\r') { _end += 1; _absorbedNewlines += 1; }
604
+ _ranges.push({ start: _scan, end: _end });
605
+ _scan = _scan + old_string.length;
606
+ }
607
+ content = _replaceRangesFromOriginal(content, _ranges, '');
608
+ _bumpRollingSnapshot(_contentBeforeEdit, old_string, _perOccurrenceDelta * _ranges.length - _absorbedNewlines, replace_all);
609
+ continue;
610
+ }
611
+ content = content.split(old_string).join(_replacementForOriginalSlice(_indentFixedNewAll, old_string, content));
612
+ _bumpRollingSnapshot(_contentBeforeEdit, old_string, _perOccurrenceDelta * _occurrences, replace_all);
613
+ continue;
614
+ }
615
+ const crlfMatch = _findCrlfNormalisedMatches(content, old_string);
616
+ if (!crlfMatch || crlfMatch.ranges.length === 0) {
617
+ const _foldAmb = _foldTierAmbiguityError(content, old_string, filePath, `edit ${i} — `, [content, edits, i, _findCrlfNormalisedMatches]);
618
+ if (_foldAmb) return { ok: false, error: _foldAmb };
619
+ // Promote `not found` to code 7 (snapshot mismatch) when this
620
+ // session has already mutated the file: the old_string almost
621
+ // certainly targets pre-mutation bytes, and the caller should
622
+ // re-read before retrying instead of debugging fold tiers.
623
+ // Use fullPath — the generation cache is keyed by canonical
624
+ // resolved path (see baseMutationGeneration at function tail),
625
+ // not the raw filePath which can be relative.
626
+ // code 7 promotion removed: `_gen > 0` alone mis-classified a
627
+ // simply-wrong old_string (absent from both pre-mutation and
628
+ // current bytes) as a stale snapshot. Stay an honest code 8 —
629
+ // the code 8 hint below already covers the real pre-mutation case.
630
+ return { ok: false, error: `Error [code 8]: edit ${i} — old_string not found in ${filePath} (no exact/fold/nfc-fold/crlf-fold match).${_optionalEditMissDetails(content, old_string, _shiftedSnapshot, { path: filePath, newString: new_string, replaceAll: replace_all === true, editIndex: i }, [content, edits, i, _findCrlfNormalisedMatches])}` };
631
+ }
632
+ const _crlfOccurrences = crlfMatch.ranges.length;
633
+ content = _replaceRangesFromOriginal(content, crlfMatch.ranges, new_string);
634
+ _bumpRollingSnapshot(_contentBeforeEdit, crlfMatch.normalisedOld, _perOccurrenceDelta * _crlfOccurrences, replace_all);
635
+ _stageCounts['crlf-fold'] = (_stageCounts['crlf-fold'] || 0) + 1;
636
+ } else {
637
+ const occurrence = _oldStringLiteralOccurrence;
638
+ if (occurrence.count > 1) {
639
+ const count = _countLiteralOccurrences(content, old_string);
640
+ return { ok: false, error: `Error [code 9]: edit ${i} — old_string found ${count} times in ${filePath}${_formatStageInline(_matchInfo.stage)};${_formatMatchLines(_occurrenceLinesPlain(content, old_string), count)} set replace_all:true or provide more unique context${_diagnoseBatchPeers(content, edits, i, _findCrlfNormalisedMatches)}` };
641
+ }
642
+ if (occurrence.count === 1) {
643
+ const _indentFixedNewOne = new_string;
644
+ content = _replaceSingleLiteralAt(content, occurrence.index, old_string, _replacementForOriginalSlice(_indentFixedNewOne, old_string, content));
645
+ _bumpRollingSnapshot(_contentBeforeEdit, old_string, _perOccurrenceDelta, replace_all);
646
+ continue;
647
+ }
648
+ const crlfMatch = _findCrlfNormalisedMatches(content, old_string);
649
+ const crlfCount = crlfMatch ? crlfMatch.ranges.length : 0;
650
+ if (crlfCount === 0) {
651
+ const _foldAmb = _foldTierAmbiguityError(content, old_string, filePath, `edit ${i} — `, [content, edits, i, _findCrlfNormalisedMatches]);
652
+ if (_foldAmb) return { ok: false, error: _foldAmb };
653
+ // Snapshot-mismatch promotion (see replace_all branch above):
654
+ // a mutated file with a missing old_string is almost always
655
+ // a stale snapshot, not a typo. Use fullPath for the cache key.
656
+ // code 7 promotion removed: `_gen > 0` alone mis-classified a
657
+ // simply-wrong old_string (absent from both pre-mutation and
658
+ // current bytes) as a stale snapshot. Stay an honest code 8 —
659
+ // the code 8 hint below already covers the real pre-mutation case.
660
+ return { ok: false, error: `Error [code 8]: edit ${i} — old_string not found in ${filePath} (no exact/fold/nfc-fold/crlf-fold match).${_optionalEditMissDetails(content, old_string, _shiftedSnapshot, { path: filePath, newString: new_string, replaceAll: replace_all === true, editIndex: i }, [content, edits, i, _findCrlfNormalisedMatches])}` };
661
+ }
662
+ if (crlfCount > 1) return { ok: false, error: `Error [code 9]: edit ${i} — old_string found ${crlfCount} times in ${filePath} (via crlf-fold);${_formatMatchLines(_occurrenceLinesCrlf(content, crlfMatch.ranges), crlfCount)} set replace_all:true or provide more unique context${_diagnoseBatchPeers(content, edits, i, _findCrlfNormalisedMatches)}` };
663
+ content = _replaceRangesFromOriginal(content, crlfMatch.ranges, new_string);
664
+ _bumpRollingSnapshot(_contentBeforeEdit, crlfMatch.normalisedOld, _perOccurrenceDelta, replace_all);
665
+ _stageCounts['crlf-fold'] = (_stageCounts['crlf-fold'] || 0) + 1;
666
+ }
667
+ }
668
+ return { ok: true, filePath, fullPath, edits, snapshot: _rollingSnapshot, content, baseRawContent: rawContent, baseContentHash, baseMutationGeneration: _getPathMutationGeneration(fullPath), baseStatSnapshot, expectedTargetSnapshot, baseMode: mEditStat.mode & 0o777, stageCounts: _stageCounts };
669
+ } catch (err) {
670
+ return { ok: false, error: `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}` };
671
+ }
672
+ }
673
+
674
+ const _editCommitHooks = {
675
+ ioTraceStart: _ioTraceStart,
676
+ ioTraceDone: _ioTraceDone,
677
+ };
678
+
679
+ function _commitPreparedEditUnlocked(prepared, readStateScope, options = {}) {
680
+ return _commitPreparedEditUnlockedImpl(prepared, readStateScope, options, _editCommitHooks);
681
+ }
682
+
683
+ function _commitPreparedEditCheckedUnlocked(prepared, readStateScope, options = {}) {
684
+ return _commitPreparedEditCheckedUnlockedImpl(prepared, readStateScope, options, _editCommitHooks);
685
+ }
686
+
687
+ export async function runMultiEdit(args, workDir, readStateScope, _pathOpts, options = {}) {
688
+ const filePath = normalizePathAndStripLineCoordinate(args?.path, workDir);
689
+ if (!filePath) return 'Error: path is required';
690
+ // R12: Win32 component guard — reject trailing dot/space or NTFS ADS
691
+ // suffix (foo.txt:ads) and reserved device names before resolve so a
692
+ // relative path can't be coerced into a device alias.
693
+ if (typeof isWindowsDevicePath === 'function' && isWindowsDevicePath(filePath)) {
694
+ return `Error: cannot edit Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(filePath)}`;
695
+ }
696
+ if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(filePath)) {
697
+ return `Error: cannot edit Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(filePath)}`;
698
+ }
699
+ const fullPath = resolveAgainstCwd(filePath, workDir);
700
+ if (typeof isWindowsDevicePath === 'function' && isWindowsDevicePath(fullPath)) {
701
+ return `Error: cannot edit Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(filePath)}`;
702
+ }
703
+ if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(fullPath)) {
704
+ return `Error: cannot edit Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(filePath)}`;
705
+ }
706
+ return withBuiltinPathLocks([fullPath], () => withAdvisoryLocks([fullPath], async () => {
707
+ const prepared = await _prepareMultiEdit({ ...args, path: filePath }, workDir, readStateScope, _pathOpts, options);
708
+ if (!prepared.ok) return prepared.error;
709
+ try {
710
+ const commit = await _commitPreparedEditCheckedUnlocked(prepared, readStateScope, options);
711
+ if (!commit.ok) return commit.error;
712
+ } catch (err) {
713
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
714
+ }
715
+ return `Edited: ${normalizeOutputPath(prepared.filePath)} (${prepared.edits.length} replacements applied)${_formatStageNote(prepared.stageCounts)}`;
716
+ }));
717
+ }
718
+
719
+ export async function runBatchEdit(args, workDir, readStateScope, _pathOpts, executeChildBuiltinTool, options = {}) {
720
+ const edits = Array.isArray(args.edits) ? args.edits : [];
721
+ if (edits.length === 0) return 'Error: edits array is required';
722
+ // Shallow-copy each entry so we don't mutate the caller's array in
723
+ // place. The previous shape rewrote `e.path` on the caller's object,
724
+ // which leaks the normalised path back out and breaks idempotent
725
+ // retries from the caller's perspective.
726
+ const normalizedEdits = edits.map((e) => (
727
+ e && typeof e === 'object'
728
+ ? { ...e, path: normalizePathAndStripLineCoordinate(e.path, workDir) }
729
+ : e
730
+ ));
731
+ const groups = new Map();
732
+ const missingPath = [];
733
+ for (const e of normalizedEdits) {
734
+ if (!e || !e.path) { missingPath.push(e); continue; }
735
+ if (!groups.has(e.path)) groups.set(e.path, []);
736
+ groups.get(e.path).push(e);
737
+ }
738
+ const parseLeadError = (body) => {
739
+ const first = String(body).split('\n')[0] || '';
740
+ if (!/^Error(\s|\[)/.test(first)) return null;
741
+ const colonIdx = first.indexOf(': ');
742
+ const msg = colonIdx !== -1 ? first.slice(colonIdx + 2) : first;
743
+ const retryHint = String(body).includes('snapshot recorded now') && !msg.includes('Retry the edit directly')
744
+ ? ' (snapshot recorded; retry the same edit directly, no read needed)'
745
+ : '';
746
+ return `${msg}${retryHint}`;
747
+ };
748
+ const preparedResults = await Promise.all([...groups.entries()].map(async ([path, items]) => {
749
+ const prepared = await _prepareMultiEdit({
750
+ path,
751
+ edits: items.map(({ path: _p, ...rest }) => rest),
752
+ }, workDir, readStateScope, null, options);
753
+ if (!prepared.ok) {
754
+ const errMsg = parseLeadError(prepared.error) || prepared.error;
755
+ return { ok: false, path, line: `FAIL ${normalizeOutputPath(path)}: ${errMsg}` };
756
+ }
757
+ return { ok: true, path, items, prepared, line: `OK ${normalizeOutputPath(path)} (${items.length})${_formatStageNote(prepared.stageCounts)}` };
758
+ }));
759
+ const missingLines = missingPath.map(() => 'FAIL (missing-path): path is required');
760
+ const lines = [...preparedResults.map((result) => result.line), ...missingLines];
761
+ const failed = lines.filter((line) => line.startsWith('FAIL ')).length;
762
+ if (failed > 0) {
763
+ return `Error: batch edit preflight failed (${failed} of ${lines.length}); no changes written\n${lines.join('\n')}`;
764
+ }
765
+ const batchLockPaths = preparedResults.map((result) => result.prepared.fullPath);
766
+ return withBuiltinPathLocks(batchLockPaths, () => withAdvisoryLocks(batchLockPaths, async () => {
767
+ const lockPreparedResults = [];
768
+ for (const result of preparedResults) {
769
+ const prepared = await _prepareMultiEdit({
770
+ path: result.path,
771
+ edits: result.items.map(({ path: _p, ...rest }) => rest),
772
+ }, workDir, readStateScope, null, options);
773
+ if (!prepared.ok) {
774
+ const errMsg = parseLeadError(prepared.error) || prepared.error;
775
+ return `Error: batch edit lock prepare failed for ${normalizeOutputPath(result.path)}: ${errMsg}; no changes written`;
776
+ }
777
+ lockPreparedResults.push({
778
+ ok: true,
779
+ path: result.path,
780
+ items: result.items,
781
+ prepared,
782
+ line: `OK ${normalizeOutputPath(result.path)} (${result.items.length})${_formatStageNote(prepared.stageCounts)}`,
783
+ });
784
+ }
785
+ const prewriteResults = lockPreparedResults.map((result) => {
786
+ const err = _validatePreparedEditBase(result.prepared);
787
+ return err ? `FAIL ${normalizeOutputPath(result.path)}: ${err}` : result.line;
788
+ });
789
+ const prewriteFailed = prewriteResults.filter((line) => line.startsWith('FAIL ')).length;
790
+ if (prewriteFailed > 0) {
791
+ return `Error: batch edit prewrite check failed (${prewriteFailed} of ${lockPreparedResults.length}); no changes written\n${prewriteResults.join('\n')}`;
792
+ }
793
+ // Cross-file atomicity (capture-and-restore): snapshot every
794
+ // target's original bytes BEFORE any write. On the first commit
795
+ // failure, restore every already-committed file from its captured
796
+ // bytes so the batch is all-or-nothing. Partial state is reported
797
+ // only when a restore itself fails — an invariant-based recovery,
798
+ // no heuristic fallback.
799
+ const originals = new Map();
800
+ for (const result of lockPreparedResults) {
801
+ const captured = result.prepared.baseRawContent;
802
+ if (!Buffer.isBuffer(captured)) {
803
+ return `Error: batch edit pre-capture failed for ${normalizeOutputPath(result.path)}: missing base bytes from prepare; no changes written`;
804
+ }
805
+ originals.set(result.prepared.fullPath, captured);
806
+ }
807
+ const commitResults = [];
808
+ const committed = [];
809
+ let commitFailureIndex = -1;
810
+ for (let i = 0; i < lockPreparedResults.length; i++) {
811
+ const result = lockPreparedResults[i];
812
+ try {
813
+ await _commitPreparedEditUnlocked(result.prepared, readStateScope, options);
814
+ commitResults.push(result.line);
815
+ committed.push(result);
816
+ } catch (err) {
817
+ commitResults.push(`FAIL ${normalizeOutputPath(result.path)}: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`);
818
+ commitFailureIndex = i;
819
+ break;
820
+ }
821
+ }
822
+ if (commitFailureIndex !== -1) {
823
+ // Reviewer issue #1: include the FAILING target in the rollback
824
+ // set. _commitPreparedEditUnlocked can mutate the target before
825
+ // throwing — same-size in-place byte writes happen via
826
+ // tryWriteSameSizeByteReplacementsSync BEFORE the error throw,
827
+ // and the post-atomicWrite side effects (cache seed / snapshot
828
+ // record) can also throw after a successful rename. If we only
829
+ // restore `committed`, the failing target keeps the partial /
830
+ // post-atomic mutation while reporting "no changes written".
831
+ const restoreFailures = [];
832
+ const restoreTargets = [...committed, lockPreparedResults[commitFailureIndex]];
833
+ for (const prior of restoreTargets) {
834
+ const orig = originals.get(prior.prepared.fullPath);
835
+ try {
836
+ await atomicWrite(prior.prepared.fullPath, orig, {
837
+ sessionId: options?.sessionId,
838
+ mode: prior.prepared.baseMode,
839
+ expectedTargetSnapshot: _captureExpectedTargetSnapshot(prior.prepared.fullPath),
840
+ });
841
+ invalidateBuiltinResultCache([prior.prepared.fullPath]);
842
+ _seedRawContentCacheAfterWrite(prior.prepared.fullPath, orig);
843
+ markCodeGraphDirtyPaths([prior.prepared.fullPath]);
844
+ // Restore the read-snapshot too so subsequent reads
845
+ // observe the original bytes — otherwise a downstream
846
+ // edit/read would see the post-commit stale snapshot
847
+ // (recorded by _commitPreparedEditUnlocked on the
848
+ // already-rolled-back files) and fail code 7.
849
+ let _restoreStat = null;
850
+ try { _restoreStat = statSync(prior.prepared.fullPath); } catch {}
851
+ _recordReadSnapshot(prior.prepared.fullPath, _restoreStat || undefined, readStateScope, {
852
+ source: 'edit_rollback',
853
+ contentHash: _hashText(orig),
854
+ });
855
+ } catch (rerr) {
856
+ restoreFailures.push(`FAIL ${normalizeOutputPath(prior.path)} (restore): ${normalizeErrorMessage(rerr instanceof Error ? rerr.message : String(rerr))}`);
857
+ }
858
+ }
859
+ if (restoreFailures.length > 0) {
860
+ return `Error: batch edit write failed and rollback incomplete (${restoreFailures.length} of ${restoreTargets.length} file(s) left in mutated state); manual recovery required\n${commitResults.join('\n')}\n${restoreFailures.join('\n')}`;
861
+ }
862
+ return `Error: batch edit write failed (1 of ${lockPreparedResults.length}); rolled back ${restoreTargets.length} file(s) (including failing target); no changes written\n${commitResults.join('\n')}`;
863
+ }
864
+ return commitResults.join('\n');
865
+ }));
866
+ }
867
+
868
+ // --- Tool execution ---
869
+ export async function runSingleEdit(args, workDir, readStateScope, options = {}) {
870
+ args.path = normalizePathAndStripLineCoordinate(args.path, workDir);
871
+ const filePath = args.path;
872
+ let oldStr = args.old_string;
873
+ let newStr = args.new_string;
874
+ const replaceAll = args.replace_all === true;
875
+ if (!filePath || typeof oldStr !== 'string' || oldStr.length === 0)
876
+ return 'Error: path and non-empty old_string are required.';
877
+ if (typeof newStr !== 'string')
878
+ return 'Error: new_string must be a string';
879
+ {
880
+ const _nulIdx = newStr.indexOf('\u0000');
881
+ if (_nulIdx !== -1)
882
+ return `Error [code 11]: new_string contains NUL byte (U+0000) at offset ${_nulIdx} — source text must not contain NUL: ${filePath}`;
883
+ }
884
+ if (newStr === oldStr)
885
+ return 'Error: new_string must differ from old_string';
886
+ if (typeof isWindowsDevicePath === 'function' && isWindowsDevicePath(filePath)) {
887
+ return `Error: cannot edit Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(filePath)}`;
888
+ }
889
+ if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(filePath)) {
890
+ return `Error: cannot edit Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(filePath)}`;
891
+ }
892
+ // Size gate moved to the FOLD-FALLBACK path below
893
+ // (post-_tryBuildExactEditBuffer, before _findActualString).
894
+ // An exact-unique byte match is safe at any size; the
895
+ // >=30-line code-10 wording only fires once the buffered
896
+ // exact attempt has missed and we are about to enter the
897
+ // fragile fold/fuzzy tier.
898
+ // Line-prefix recovery: Read returns `<n>│<content>` (legacy `\t`
899
+ // also covered for muscle-memory pastes). If the model copies
900
+ // that rendering straight into old_string the on-disk file has
901
+ // no matching separator-prefixed line, so the match would
902
+ // silently fail. Auto-strip when every line carries a prefix;
903
+ // surface a guidance error when only some lines do, since
904
+ // mixing rendered + raw lines means we cannot infer intent.
905
+ if (/^\s*\d+[\t│→]/.test(oldStr)) {
906
+ const _stripped = _maybeAutoStripLineNumberPrefixes(oldStr);
907
+ if (_stripped !== null) {
908
+ _editPathTrace('edit_auto_strip_line_numbers', filePath, { mode: 'single' });
909
+ oldStr = _stripped;
910
+ if (newStr === oldStr)
911
+ return 'Error: new_string must differ from old_string (after auto-stripping Read line-number prefix from old_string)';
912
+ } else {
913
+ return `Error: old_string mixes Read line-number-prefixed lines ("<n>│…") with raw lines — strip the prefix from every line (or none) before Edit: ${filePath}`;
914
+ }
915
+ }
916
+ // CC parity (FileEditTool.stripTrailingWhitespace): drop trailing
917
+ // space/tab from each new_string line. Models routinely emit stray
918
+ // spaces at line ends; in source code that has no semantic meaning,
919
+ // so silent diffs from those bytes are pure noise. Markdown is the
920
+ // sole exception — `" \n"` is the hard-line-break syntax (`<br>`),
921
+ // altering it changes rendered output. Line terminators (LF / CRLF
922
+ // / lone CR) are preserved byte-exact.
923
+ if (!/\.(?:md|mdx)$/i.test(filePath)) {
924
+ const _strippedNew = _stripTrailingWhitespaceForEdit(newStr, oldStr);
925
+ if (_strippedNew !== newStr) {
926
+ _editPathTrace('edit_trim_trailing_ws', filePath, { mode: 'single' });
927
+ newStr = _strippedNew;
928
+ if (newStr === oldStr)
929
+ return 'Error: new_string must differ from old_string (after trimming trailing whitespace from new_string; rename target to .md/.mdx to preserve trailing spaces)';
930
+ }
931
+ }
932
+ const fullPath = resolveAgainstCwd(filePath, workDir);
933
+ // R1: short-circuit UNC/SMB paths before ANY stat/read on the
934
+ // edit target to prevent NTLM credential leakage via implicit
935
+ // network auth. Mirrors CC FileEditTool.ts:176.
936
+ if (fullPath.startsWith('\\\\') || fullPath.startsWith('//')) {
937
+ return `Error: UNC/SMB paths are not supported (R1: NTLM-leak prevention): ${filePath}`;
938
+ }
939
+ if (typeof isWindowsDevicePath === 'function' && isWindowsDevicePath(fullPath)) {
940
+ return `Error: cannot edit Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(filePath)}`;
941
+ }
942
+ if (typeof hasUnsafeWin32Component === 'function' && hasUnsafeWin32Component(fullPath)) {
943
+ return `Error: cannot edit Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(filePath)}`;
944
+ }
945
+ // Fast path: when a snapshot already exists, skip the pre-lock stat.
946
+ // The lock-protected stat below still catches ENOENT and drift; the
947
+ // pre-lock stat is only needed to seed auto-snapshot on cold edits.
948
+ let _coldPrimedContent = null;
949
+ let _coldPrimedRawBuf = null;
950
+ let _coldPrimedStat = null;
951
+ {
952
+ let _preLockSnap = _getReadSnapshot(fullPath, readStateScope);
953
+ if (!_preLockSnap) {
954
+ let _preLockStat;
955
+ try { _preLockStat = statSync(fullPath); }
956
+ catch (err) {
957
+ if (err && err.code === 'ENOENT') {
958
+ const similar = findSimilarFile(fullPath);
959
+ const hint = similar ? ` Did you mean "${normalizeOutputPath(similar)}"?` : '';
960
+ return `Error [code 4]: file not found: ${filePath}${hint}`;
961
+ }
962
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
963
+ }
964
+ // Auto-snapshot: same invariant as _runMultiEdit. Capture
965
+ // in-process, continue. Lock-protected re-snapshot inside
966
+ // _withPathLock below still catches concurrent external
967
+ // writes, so the auto path doesn't weaken CAS guarantees.
968
+ const _sPrimed = _primeReadSnapshotForEdit({
969
+ fullPath,
970
+ filePath,
971
+ st: _preLockStat,
972
+ scope: readStateScope,
973
+ oldStrings: [],
974
+ });
975
+ _preLockSnap = _getReadSnapshot(fullPath, readStateScope);
976
+ if (_sPrimed) {
977
+ _editPathTrace('edit_auto_snapshot', filePath, { mode: 'single' });
978
+ if (typeof _sPrimed.content === 'string' && Buffer.isBuffer(_sPrimed.rawBuf)) {
979
+ _coldPrimedContent = _sPrimed.content;
980
+ _coldPrimedRawBuf = _sPrimed.rawBuf;
981
+ _coldPrimedStat = _preLockStat;
982
+ }
983
+ } else if (!_preLockSnap) {
984
+ const _cold = _loadEditTargetBytes(fullPath);
985
+ if (!_cold) {
986
+ return `Error: failed to read edit target: ${filePath}`;
987
+ }
988
+ _coldPrimedContent = _cold.content;
989
+ _coldPrimedRawBuf = _cold.rawBuf;
990
+ _coldPrimedStat = _preLockStat;
991
+ _editPathTrace('edit_cold_no_snapshot', filePath, { mode: 'single' });
992
+ }
993
+ }
994
+ }
995
+ // CAS guard: serialise concurrent edits to the same path.
996
+ // After acquiring the lock, re-stat + re-hash to detect drift
997
+ // that occurred between the pre-lock snapshot check and now.
998
+ return _withPathLock(fullPath, () => withAdvisoryLocks([fullPath], async () => {
999
+ let editStat;
1000
+ try { editStat = statSync(fullPath); }
1001
+ catch (err) {
1002
+ if (err && err.code === 'ENOENT') {
1003
+ const similar = findSimilarFile(fullPath);
1004
+ const hint = similar ? ` Did you mean "${normalizeOutputPath(similar)}"?` : '';
1005
+ return `Error [code 4]: file not found: ${filePath}${hint}`;
1006
+ }
1007
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
1008
+ }
1009
+ let editSnapshot = _getReadSnapshot(fullPath, readStateScope);
1010
+ // Error [code 7]: detect stale read via mtime drift (Anthropic
1011
+ // readFileState timestamp check parity). +1ms slack absorbs
1012
+ // filesystem timestamp resolution noise on NTFS/exFAT.
1013
+ let editPreloadedContent = null;
1014
+ let editPreloadedRawBuf = null;
1015
+ const editSnapshotReadCache = createMutationContentCache();
1016
+ if (!editSnapshot) {
1017
+ const _cold = _readEditTargetBytesUnderLock(fullPath, filePath, 'no_snapshot');
1018
+ if (!_cold) {
1019
+ return `Error: failed to read edit target: ${filePath}`;
1020
+ }
1021
+ editPreloadedContent = _cold.content;
1022
+ editPreloadedRawBuf = _cold.rawBuf;
1023
+ } else {
1024
+ const _editSnapStale = _isSnapshotStale(editStat, editSnapshot, fullPath, editSnapshotReadCache);
1025
+ if (!_editSnapStale && _coldPrimedContent !== null && editSnapshot
1026
+ && typeof editSnapshot.contentHash === 'string'
1027
+ && _coldPrimedStat && _statMatchesSnapshot(editStat, _coldPrimedStat)) {
1028
+ const _lockedPrimed = _loadEditTargetBytes(fullPath);
1029
+ if (_lockedPrimed) {
1030
+ if (Buffer.isBuffer(_coldPrimedRawBuf)
1031
+ && _hashText(_lockedPrimed.rawBuf) !== _hashText(_coldPrimedRawBuf)) {
1032
+ _editPathTrace('edit_lock_cold_reread', filePath, {
1033
+ mode: 'single',
1034
+ reason: 'auto_snapshot_content_drift',
1035
+ });
1036
+ }
1037
+ if (_hashText(_lockedPrimed.rawBuf) === editSnapshot.contentHash) {
1038
+ editPreloadedContent = _lockedPrimed.content;
1039
+ editPreloadedRawBuf = _lockedPrimed.rawBuf;
1040
+ }
1041
+ }
1042
+ }
1043
+ if (_editSnapStale) {
1044
+ editPreloadedContent = _readContentIfSnapshotHashMatches(fullPath, editSnapshot, editSnapshotReadCache, editStat);
1045
+ if (editPreloadedContent !== null) {
1046
+ const cached = editSnapshotReadCache.getEntry(fullPath);
1047
+ if (Buffer.isBuffer(cached?.rawBuf)) editPreloadedRawBuf = cached.rawBuf;
1048
+ }
1049
+ if (editPreloadedContent === null) {
1050
+ const _staleRefresh = _tryStaleSnapshotAutoRefresh({
1051
+ fullPath,
1052
+ filePath,
1053
+ scope: readStateScope,
1054
+ stat: editStat,
1055
+ readRanges: editSnapshot?.ranges,
1056
+ oldStrings: [{ old_string: oldStr, replace_all: replaceAll }],
1057
+ readCache: editSnapshotReadCache,
1058
+ recordPreviewSnapshot: !replaceAll,
1059
+ });
1060
+ if (_staleRefresh?.error) return _staleRefresh.error;
1061
+ if (_staleRefresh?.content) {
1062
+ editPreloadedContent = _staleRefresh.content;
1063
+ editPreloadedRawBuf = _staleRefresh.rawBuf;
1064
+ editSnapshot = _getReadSnapshot(fullPath, readStateScope);
1065
+ } else {
1066
+ const recovery = _buildStaleEditRecovery({
1067
+ fullPath,
1068
+ scope: readStateScope,
1069
+ oldStrings: [oldStr],
1070
+ recordPreviewSnapshot: !replaceAll,
1071
+ });
1072
+ return `Error [code 7]: file modified since read (lint / formatter / external write) — read it again before editing: ${filePath}${recovery}`;
1073
+ }
1074
+ }
1075
+ } else {
1076
+ const cached = editSnapshotReadCache.getEntry(fullPath);
1077
+ if (typeof cached?.content === 'string' && Buffer.isBuffer(cached.rawBuf)) {
1078
+ editPreloadedContent = cached.content;
1079
+ editPreloadedRawBuf = cached.rawBuf;
1080
+ }
1081
+ }
1082
+ }
1083
+ try {
1084
+ if (editStat.size > 1073741824) {
1085
+ return `Error: edit refused: file too large (size: ${editStat.size}B, cap: 1GiB)`;
1086
+ }
1087
+ // Reviewer issue #3: validate encoding before the native
1088
+ // exact-edit dispatch too. Without this, the rust binary
1089
+ // would happily rewrite a Shift-JIS / Latin-1 / binary
1090
+ // file on the byte-exact code path while only the JS
1091
+ // fold path refused it. We read the buffer once, run
1092
+ // the shared guard, and reuse it downstream so non-
1093
+ // native paths don't double-read.
1094
+ let _preNativeRawBuf = editPreloadedRawBuf !== null
1095
+ ? editPreloadedRawBuf
1096
+ : (_rawContentCacheGet(fullPath, editStat)
1097
+ || (editPreloadedContent === null
1098
+ ? await fsPromises.readFile(fullPath)
1099
+ : Buffer.from(editPreloadedContent, 'utf-8')));
1100
+ {
1101
+ const _utf8Err = _assertEditTargetUtf8(_preNativeRawBuf, filePath);
1102
+ if (_utf8Err) return _utf8Err;
1103
+ }
1104
+ if (_nativeEditShouldAttempt({ editStat, editSnapshot, oldStr, newStr, preloadedContent: editPreloadedContent, preloadedRawBuf: editPreloadedRawBuf })) {
1105
+ let nativeSignal = options?.signal || null;
1106
+ if (!nativeSignal && options?.sessionId) {
1107
+ try { nativeSignal = await getAbortSignalForSession(options.sessionId); } catch { nativeSignal = null; }
1108
+ }
1109
+ const nativeEdit = await _runNativeExactEdit({ fullPath, oldStr, newStr, replaceAll, signal: nativeSignal });
1110
+ if (nativeEdit?.ok) {
1111
+ invalidateBuiltinResultCache([fullPath]);
1112
+ markCodeGraphDirtyPaths([fullPath]);
1113
+ let writtenStat = null;
1114
+ try { writtenStat = statSync(fullPath); } catch {}
1115
+ _recordReadSnapshot(fullPath, writtenStat || undefined, readStateScope, {
1116
+ source: 'edit_native',
1117
+ contentHash: nativeEdit.contentHash,
1118
+ });
1119
+ _ioTrace('edit_native', {
1120
+ pathHash: _hashText(fullPath).slice(0, 12),
1121
+ replacements: nativeEdit.replacements,
1122
+ roundtripMs: Number(nativeEdit.roundtripMs.toFixed(3)),
1123
+ rustTotalMs: Number(nativeEdit.totalMs.toFixed(3)),
1124
+ readMs: Number(nativeEdit.readMs.toFixed(3)),
1125
+ applyMs: Number(nativeEdit.applyMs.toFixed(3)),
1126
+ writeMs: Number(nativeEdit.writeMs.toFixed(3)),
1127
+ });
1128
+ return `Edited: ${normalizeOutputPath(filePath)} (native)`;
1129
+ }
1130
+ if (nativeEdit && nativeEdit.fallback === false) {
1131
+ return `Error: native edit failed — ${normalizeErrorMessage(nativeEdit.error || 'unknown native edit error')}`;
1132
+ }
1133
+ }
1134
+ // D-R1-3: refuse edits on non-UTF-8 files before the
1135
+ // utf-8 decode round-trip silently corrupts bytes via
1136
+ // U+FFFD replacement. Use Buffer.isUtf8 (Node>=18) or
1137
+ // a byte-level walk as fallback.
1138
+ // Fix J-3: always read raw bytes and validate encoding,
1139
+ // even when editPreloadedContent was set via contentHash
1140
+ // preload — the cached string bypasses the guard otherwise.
1141
+ // I/O perf: single async Buffer read serves both UTF-8
1142
+ // validation AND content matching below; previous shape
1143
+ // did two sync readFileSync inside _withPathLock.
1144
+ const _rawBuf = _preNativeRawBuf;
1145
+ const _baseStatSnapshot = _captureStableBaseStatSnapshot(fullPath, editStat, _rawBuf);
1146
+ const _baseContentHash = _hashText(_rawBuf);
1147
+ const _baseMutationGen = _getPathMutationGeneration(fullPath);
1148
+ const _preWriteBaseCheck = () => _validatePreparedEditBase({
1149
+ fullPath,
1150
+ filePath,
1151
+ baseStatSnapshot: _baseStatSnapshot,
1152
+ baseContentHash: _baseContentHash,
1153
+ baseMutationGeneration: _baseMutationGen,
1154
+ });
1155
+ // UTF-8 already validated against _preNativeRawBuf above
1156
+ // (covers native dispatch + byte-exact + fold paths).
1157
+ // The shared `_assertEditTargetUtf8` helper enforces
1158
+ // the same error wording for every byte-exact write
1159
+ // path (single byte-exact buffer, multi byte-exact
1160
+ // buffer, native exact-edit).
1161
+ const _byteExactEdit = _tryBuildExactEditBuffer(_rawBuf, oldStr, newStr, replaceAll, editSnapshot, filePath);
1162
+ if (_byteExactEdit?.error) return _byteExactEdit.error;
1163
+ if (_byteExactEdit?.sameSize && Array.isArray(_byteExactEdit.replacements)) {
1164
+ const partial = _tryWriteSameSizeByteReplacementsSync(fullPath, _byteExactEdit.replacements, {
1165
+ baseStatSnapshot: _baseStatSnapshot,
1166
+ baseMutationGeneration: _baseMutationGen,
1167
+ baseContentHash: _baseContentHash,
1168
+ contentHash: _byteExactEdit.contentHash,
1169
+ fsync: options?.fsync,
1170
+ filePath,
1171
+ });
1172
+ if (partial?.error) return partial.error;
1173
+ if (partial?.ok) {
1174
+ invalidateBuiltinResultCache([fullPath]);
1175
+ markCodeGraphDirtyPaths([fullPath]);
1176
+ const _partialAfter = _materialiseByteReplacements(_rawBuf, _byteExactEdit.replacements);
1177
+ const _partialSnap = _postEditSnapshotMeta(editSnapshot, 'edit', _partialAfter, {
1178
+ contentBeforeEdit: _rawBuf,
1179
+ oldStr,
1180
+ newStr,
1181
+ replaceAll,
1182
+ });
1183
+ _recordReadSnapshot(fullPath, partial.stat || undefined, readStateScope, _partialSnap);
1184
+ return `Edited: ${normalizeOutputPath(filePath)}`;
1185
+ }
1186
+ }
1187
+ if (!Buffer.isBuffer(_byteExactEdit?.updated) && Array.isArray(_byteExactEdit?.replacements)) {
1188
+ _byteExactEdit.updated = _materialiseByteReplacements(_rawBuf, _byteExactEdit.replacements);
1189
+ }
1190
+ if (Buffer.isBuffer(_byteExactEdit?.updated)) {
1191
+ const _baseErr = _preWriteBaseCheck();
1192
+ if (_baseErr) return _baseErr;
1193
+ await atomicWrite(fullPath, _byteExactEdit.updated, {
1194
+ sessionId: options?.sessionId,
1195
+ mode: editStat.mode & 0o777,
1196
+ expectedTargetSnapshot: _captureExpectedTargetSnapshot(fullPath, editStat),
1197
+ });
1198
+ invalidateBuiltinResultCache([fullPath]);
1199
+ const writtenStat = _seedRawContentCacheAfterWrite(fullPath, _byteExactEdit.updated);
1200
+ markCodeGraphDirtyPaths([fullPath]);
1201
+ _recordReadSnapshot(fullPath, writtenStat || undefined, readStateScope, _postEditSnapshotMeta(editSnapshot, 'edit', _byteExactEdit.updated, {
1202
+ contentBeforeEdit: _rawBuf,
1203
+ oldStr,
1204
+ newStr,
1205
+ replaceAll,
1206
+ }));
1207
+ return `Edited: ${normalizeOutputPath(filePath)}`;
1208
+ }
1209
+ let content = editPreloadedContent === null
1210
+ ? _rawBuf.toString('utf-8')
1211
+ : editPreloadedContent;
1212
+ // CC parity: pure deletion (newStr === '') swallows the
1213
+ // trailing newline that follows oldStr in the file so the
1214
+ // edit doesn't leave a stray empty line. Only the literal
1215
+ // match path absorbs; CRLF fallback keeps existing
1216
+ // semantics so range arithmetic stays simple.
1217
+ // CC parity: also try curly-quote normalization to find the
1218
+ // file's actual substring when the model emitted straight
1219
+ // quotes against a curly-quoted source (or vice versa).
1220
+ // _findActualString returns the byte-exact slice that the
1221
+ // file holds at the match position; downstream code keeps
1222
+ // operating on that exact slice.
1223
+ const _matchInfo = {};
1224
+ const _oldLiteralOccurrence = _findLiteralOccurrenceState(content, oldStr);
1225
+ // Fold-fallback size gate: only when the buffered
1226
+ // exact attempt has returned null AND no literal
1227
+ // hit survives in the decoded `content` view do we
1228
+ // refuse large chunks. An exact-unique landed edit
1229
+ // (handled above via _byteExactEdit.updated) never
1230
+ // reaches here.
1231
+ if (_oldLiteralOccurrence.count === 0) {
1232
+ const _foldSizeErr = _validateEditChunkSize(oldStr, replaceAll, false);
1233
+ if (_foldSizeErr) return _foldSizeErr;
1234
+ }
1235
+ const _matchedOldStr = _oldLiteralOccurrence.count > 0
1236
+ ? oldStr
1237
+ : (_findActualString(content, oldStr, _matchInfo) || oldStr);
1238
+ if (_oldLiteralOccurrence.count > 0) _matchInfo.stage = 'exact';
1239
+ // CC parity: preserve the file's curly-quote typography when
1240
+ // the model wrote straight quotes and the matched bytes had
1241
+ // curly ones. Heuristic exception — see edit-normalize.mjs
1242
+ // preserveQuoteTypography for the carved-out justification.
1243
+ const _newStrAfterTypo = _preserveQuoteTypography(oldStr, _matchedOldStr, newStr);
1244
+ if (_newStrAfterTypo !== newStr) {
1245
+ _editPathTrace('edit_typography_preserve', filePath, { mode: 'single' });
1246
+ newStr = _newStrAfterTypo;
1247
+ }
1248
+ // Pure-deletion newline absorption.
1249
+ //
1250
+ // Single-occurrence path: extend the match over a trailing
1251
+ // LF/CRLF when present so the deletion doesn't leave a stray
1252
+ // empty line (CC parity). Previous shape used a global
1253
+ // `content.includes(X+'\n')` probe and rewrote eOldStr to
1254
+ // `X+'\n'`; under replace_all that left bare-X occurrences
1255
+ // (no following newline) unmatched. Switch to per-
1256
+ // occurrence range collection below when replace_all is
1257
+ // set, and keep the simple form for single replace.
1258
+ let eOldStr = _matchedOldStr;
1259
+ let _pureDeletionRanges = null;
1260
+ if (newStr === '' && !_matchedOldStr.endsWith('\n')) {
1261
+ if (replaceAll) {
1262
+ const ranges = [];
1263
+ let scan = 0;
1264
+ while ((scan = content.indexOf(_matchedOldStr, scan)) !== -1) {
1265
+ let end = scan + _matchedOldStr.length;
1266
+ if (content[end] === '\r' && content[end + 1] === '\n') end += 2;
1267
+ else if (content[end] === '\n') end += 1;
1268
+ ranges.push({ start: scan, end });
1269
+ scan = scan + _matchedOldStr.length;
1270
+ }
1271
+ if (ranges.length > 0) _pureDeletionRanges = ranges;
1272
+ } else {
1273
+ // Ambiguity must be judged on the ORIGINAL bare
1274
+ // _matchedOldStr occurrence count, not the newline-
1275
+ // absorbed eOldStr. Absorbing first can collapse a
1276
+ // >1-occurrence bare match into a unique extended
1277
+ // literal (e.g. 'X' present as 'X\r\n...X'),
1278
+ // silently single-deleting instead of surfacing the
1279
+ // ambiguous-match (code 9) error below. Only absorb
1280
+ // when the bare match is unique; otherwise leave
1281
+ // eOldStr as _matchedOldStr so the occurrence.count
1282
+ // > 1 ambiguous branch fires.
1283
+ const _bareOccurrence = _findLiteralOccurrenceState(content, _matchedOldStr);
1284
+ if (_bareOccurrence.count <= 1) {
1285
+ if (content.includes(_matchedOldStr + '\r\n')) {
1286
+ eOldStr = _matchedOldStr + '\r\n';
1287
+ } else if (content.includes(_matchedOldStr + '\n')) {
1288
+ eOldStr = _matchedOldStr + '\n';
1289
+ }
1290
+ }
1291
+ }
1292
+ }
1293
+ let updated;
1294
+ const occurrence = (eOldStr === oldStr && _oldLiteralOccurrence.count > 0)
1295
+ ? _oldLiteralOccurrence
1296
+ : _findLiteralOccurrenceState(content, eOldStr);
1297
+ if (occurrence.count === 0) {
1298
+ const crlfMatch = _findCrlfNormalisedMatches(content, oldStr);
1299
+ const crlfCount = crlfMatch ? crlfMatch.ranges.length : 0;
1300
+ if (crlfCount === 0) {
1301
+ const _foldAmb = _foldTierAmbiguityError(content, oldStr, filePath);
1302
+ if (_foldAmb) return _foldAmb;
1303
+ return `Error [code 8]: old_string not found in ${filePath} (no exact/fold/nfc-fold/crlf-fold match).${_optionalEditMissDetails(content, oldStr, editSnapshot, { path: filePath, newString: newStr, replaceAll })}`;
1304
+ }
1305
+ if (crlfCount > 1 && !replaceAll)
1306
+ return `Error [code 9]: old_string found ${crlfCount} times in ${filePath} (via crlf-fold);${_formatMatchLines(_occurrenceLinesCrlf(content, crlfMatch.ranges), crlfCount)} set replace_all:true or provide more unique context`;
1307
+ updated = _replaceRangesFromOriginal(content, replaceAll ? crlfMatch.ranges : crlfMatch.ranges.slice(0, 1), newStr);
1308
+ _matchInfo.stage = 'crlf-fold';
1309
+ } else {
1310
+ if (occurrence.count > 1 && !replaceAll) {
1311
+ const count = _countLiteralOccurrences(content, eOldStr);
1312
+ return `Error [code 9]: old_string found ${count} times in ${filePath}${_formatStageInline(_matchInfo.stage)};${_formatMatchLines(_occurrenceLinesPlain(content, eOldStr), count)} set replace_all:true or provide more unique context`;
1313
+ }
1314
+ if (replaceAll && Array.isArray(_pureDeletionRanges) && _pureDeletionRanges.length > 0) {
1315
+ // Per-occurrence pure-deletion: extend each match
1316
+ // over its own trailing LF/CRLF instead of a
1317
+ // single global `eOldStr+'\n'` literal that would
1318
+ // miss bare-X occurrences. See range collection
1319
+ // above.
1320
+ updated = _replaceRangesFromOriginal(content, _pureDeletionRanges, newStr);
1321
+ } else {
1322
+ const _indentFixedNewStr = newStr;
1323
+ const _eolPreservedNewStr = _replacementForOriginalSlice(_indentFixedNewStr, eOldStr, content);
1324
+ updated = replaceAll
1325
+ ? content.split(eOldStr).join(_eolPreservedNewStr)
1326
+ : _replaceSingleLiteralAt(content, occurrence.index, eOldStr, _eolPreservedNewStr);
1327
+ }
1328
+ }
1329
+ // Atomic write — see `write` handler for rationale.
1330
+ const _baseErrFinal = _preWriteBaseCheck();
1331
+ if (_baseErrFinal) return _baseErrFinal;
1332
+ await atomicWrite(fullPath, updated, {
1333
+ sessionId: options?.sessionId,
1334
+ mode: editStat.mode & 0o777,
1335
+ expectedTargetSnapshot: _captureExpectedTargetSnapshot(fullPath, editStat),
1336
+ });
1337
+ invalidateBuiltinResultCache([fullPath]);
1338
+ const writtenStat = _seedRawContentCacheAfterWrite(fullPath, updated);
1339
+ markCodeGraphDirtyPaths([fullPath]);
1340
+ // Refresh the snapshot to the post-write mtime so a chain
1341
+ // of edits against the same file doesn't trip the stale
1342
+ // check on the second hop. Keep partial-read coverage
1343
+ // partial; an edit should not imply the model saw the
1344
+ // whole file.
1345
+ _recordReadSnapshot(fullPath, writtenStat || undefined, readStateScope, _postEditSnapshotMeta(editSnapshot, 'edit', updated, {
1346
+ contentBeforeEdit: content,
1347
+ oldStr: eOldStr,
1348
+ newStr,
1349
+ replaceAll,
1350
+ }));
1351
+ // Stage note surfaces non-exact matches so the model
1352
+ // learns to send literal bytes next time and the user
1353
+ // can spot silent typography / whitespace drift. Exact
1354
+ // matches stay terse — they are the steady-state path.
1355
+ const _stageNote = (_matchInfo.stage && _matchInfo.stage !== 'exact')
1356
+ ? _formatStageNote({ [_matchInfo.stage]: 1 })
1357
+ : '';
1358
+ return `Edited: ${normalizeOutputPath(filePath)}${_stageNote}`;
1359
+ }
1360
+ catch (err) {
1361
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
1362
+ }
1363
+ }));
1364
+ }