mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -26,18 +26,51 @@
26
26
  import { spawn } from 'node:child_process';
27
27
  import { basename } from 'node:path';
28
28
  import { createHash, randomBytes } from 'node:crypto';
29
+ import { availableParallelism, freemem } from 'node:os';
29
30
  import { killProcessTree, windowsPidHasDescendants } from '../builtin/shell-job-process.mjs';
30
31
 
32
+ const MB = 1024 * 1024;
33
+
34
+ export function _resolvePwshPoolTarget({
35
+ env = process.env,
36
+ platform = process.platform,
37
+ parallelism = null,
38
+ freeMemoryBytes = null,
39
+ } = {}) {
40
+ const configured = env?.MIXDOG_PWSH_STANDBY_POOL;
41
+ if (configured != null && configured !== '') {
42
+ const n = Math.floor(Number(configured));
43
+ if (Number.isFinite(n) && n >= 0) return Math.min(8, n);
44
+ }
45
+ if (platform !== 'win32') return 0;
46
+ // Tools now execute in the machine-global daemon. Keep client/test
47
+ // processes at zero by default, but let that ONE host maintain a short-lived
48
+ // warm burst sized to the machine instead of making every shell call pay the
49
+ // ~300ms pwsh startup cost.
50
+ if (env?.MIXDOG_DAEMON_HOST !== '1') return 0;
51
+ let cores = Number(parallelism);
52
+ if (!Number.isFinite(cores) || cores < 1) {
53
+ try { cores = availableParallelism(); } catch { cores = 1; }
54
+ }
55
+ let free = freeMemoryBytes == null ? Number.NaN : Number(freeMemoryBytes);
56
+ if (!Number.isFinite(free) || free < 0) {
57
+ try { free = freemem(); } catch { free = 0; }
58
+ }
59
+ const cpuTarget = Math.max(1, Math.min(4, Math.floor(cores) - 1 || 1));
60
+ if (free < 768 * MB) return 0;
61
+ if (free < 1536 * MB) return Math.min(1, cpuTarget);
62
+ if (free < 3072 * MB) return Math.min(2, cpuTarget);
63
+ return cpuTarget;
64
+ }
65
+
31
66
  function _poolTarget() {
32
- const n = Math.floor(Number(process.env.MIXDOG_PWSH_STANDBY_POOL));
33
- if (Number.isFinite(n) && n >= 0) return Math.min(8, n);
34
- return 2;
67
+ return _resolvePwshPoolTarget();
35
68
  }
36
69
  const POOL_TARGET = _poolTarget();
37
- // Only standbys older than this are handed out: a younger one is still paying
38
- // pwsh startup, so adopting it would just relocate the cold cost. Callers get
39
- // null (classic spawn) while the pool warms.
40
- const STANDBY_READY_AGE_MS = 400;
70
+ const _envIdleMs = Math.floor(Number(process.env.MIXDOG_PWSH_STANDBY_IDLE_MS));
71
+ const STANDBY_IDLE_MS = Number.isFinite(_envIdleMs) && _envIdleMs >= 0
72
+ ? _envIdleMs
73
+ : 60_000;
41
74
  const EXPECTED_ARGS = Object.freeze(['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']);
42
75
 
43
76
  // Marker-loop bootstrap: the standby serves MANY commands over its lifetime.
@@ -132,6 +165,7 @@ function _standbyBootScript(nonce) {
132
165
  }
133
166
 
134
167
  const _idle = [];
168
+ const _taken = new Set();
135
169
  let _envSig = null;
136
170
  let _exitHookInstalled = false;
137
171
 
@@ -144,6 +178,10 @@ function _envSignature(env) {
144
178
  }
145
179
 
146
180
  function _killEntry(entry) {
181
+ if (entry.idleTimer) {
182
+ clearTimeout(entry.idleTimer);
183
+ entry.idleTimer = null;
184
+ }
147
185
  try { entry.child.kill(); } catch { /* already down */ }
148
186
  }
149
187
 
@@ -152,6 +190,19 @@ function _discardIdle(entry) {
152
190
  if (i !== -1) _idle.splice(i, 1);
153
191
  }
154
192
 
193
+ function _armIdleExpiry(entry) {
194
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
195
+ entry.idleTimer = null;
196
+ if (STANDBY_IDLE_MS <= 0) return;
197
+ entry.idleTimer = setTimeout(() => {
198
+ entry.idleTimer = null;
199
+ if (entry.taken) return;
200
+ _discardIdle(entry);
201
+ _killEntry(entry);
202
+ }, STANDBY_IDLE_MS);
203
+ entry.idleTimer.unref?.();
204
+ }
205
+
155
206
  function _installExitHook() {
156
207
  if (_exitHookInstalled) return;
157
208
  _exitHookInstalled = true;
@@ -173,7 +224,7 @@ function _spawnStandby(shell, env, sig) {
173
224
  detached: false,
174
225
  });
175
226
  } catch { return; }
176
- const entry = { child, sig, nonce, spawnedAt: Date.now(), taken: false, consumed: false };
227
+ const entry = { child, sig, nonce, taken: false, consumed: false, idleTimer: null };
177
228
  child.on('error', () => { if (!entry.taken) { _discardIdle(entry); _killEntry(entry); } });
178
229
  child.on('exit', () => { if (!entry.taken) _discardIdle(entry); });
179
230
  // A dead standby's pending stdin write must never crash the daemon.
@@ -187,10 +238,25 @@ function _spawnStandby(shell, env, sig) {
187
238
  child.stderr?.unref?.();
188
239
  } catch { /* best-effort */ }
189
240
  _idle.push(entry);
241
+ _armIdleExpiry(entry);
242
+ }
243
+
244
+ function _poolSizeForSignature(sig) {
245
+ let count = _idle.reduce((total, entry) => total + (entry.sig === sig ? 1 : 0), 0);
246
+ for (const entry of _taken) {
247
+ if (entry.sig === sig) count += 1;
248
+ }
249
+ return count;
190
250
  }
191
251
 
192
252
  function _topUp(shell, env, sig) {
193
- while (_idle.length < POOL_TARGET) _spawnStandby(shell, env, sig);
253
+ while (_poolSizeForSignature(sig) < POOL_TARGET) _spawnStandby(shell, env, sig);
254
+ }
255
+
256
+ function _releaseTaken(entry) {
257
+ if (!_taken.delete(entry)) return false;
258
+ entry.taken = false;
259
+ return true;
194
260
  }
195
261
 
196
262
  /**
@@ -215,11 +281,9 @@ export function takePwshStandby({ shell, shellArgs, env }) {
215
281
  _envSig = sig;
216
282
  }
217
283
  _topUp(shell, env, sig);
218
- const now = Date.now();
219
284
  let entry = null;
220
285
  for (let i = 0; i < _idle.length; i += 1) {
221
286
  const cand = _idle[i];
222
- if (now - cand.spawnedAt < STANDBY_READY_AGE_MS) continue;
223
287
  if (cand.child.exitCode !== null || cand.child.signalCode !== null) continue;
224
288
  if (!cand.child.stdin || cand.child.stdin.destroyed) continue;
225
289
  _idle.splice(i, 1);
@@ -229,6 +293,11 @@ export function takePwshStandby({ shell, shellArgs, env }) {
229
293
  if (!entry) return null;
230
294
  entry.taken = true;
231
295
  entry.consumed = false;
296
+ _taken.add(entry);
297
+ if (entry.idleTimer) {
298
+ clearTimeout(entry.idleTimer);
299
+ entry.idleTimer = null;
300
+ }
232
301
  try {
233
302
  entry.child.ref();
234
303
  entry.child.stdin?.ref?.();
@@ -271,6 +340,7 @@ export function takePwshStandby({ shell, shellArgs, env }) {
271
340
  // job's close-wired exit file then records the REAL command exit.
272
341
  endStdin() {
273
342
  entry.consumed = true;
343
+ _releaseTaken(entry);
274
344
  try { entry.child.stdin.end(); } catch { /* already gone */ }
275
345
  },
276
346
  // Post-command recycle: return the standby to the pool ONLY when the
@@ -281,6 +351,7 @@ export function takePwshStandby({ shell, shellArgs, env }) {
281
351
  async recycle() {
282
352
  if (entry.consumed) return false;
283
353
  entry.consumed = true;
354
+ _releaseTaken(entry);
284
355
  const c = entry.child;
285
356
  const alive = () => c.exitCode === null && c.signalCode === null;
286
357
  let dirty = true;
@@ -309,9 +380,7 @@ export function takePwshStandby({ shell, shellArgs, env }) {
309
380
  process.stderr.write(`[pwsh-standby] recycle drained ${_staleBytes}B stale output (pid=${c.pid}) — late flush escaped the previous command's capture\n`);
310
381
  } catch { /* diagnostics only */ }
311
382
  }
312
- entry.taken = false;
313
383
  entry.consumed = false;
314
- entry.spawnedAt = Date.now() - STANDBY_READY_AGE_MS;
315
384
  try {
316
385
  c.unref();
317
386
  c.stdin?.unref?.();
@@ -319,6 +388,7 @@ export function takePwshStandby({ shell, shellArgs, env }) {
319
388
  c.stderr?.unref?.();
320
389
  } catch { /* best-effort */ }
321
390
  _idle.push(entry);
391
+ _armIdleExpiry(entry);
322
392
  return true;
323
393
  },
324
394
  };
@@ -334,7 +334,10 @@ function rustTrim(s) {
334
334
  return s.replace(RUST_TRIM_RE, '');
335
335
  }
336
336
  export function normalizeTypographic(s) {
337
- return rustTrim(String(s ?? ''))
337
+ // NFC first: a decomposed sequence (Hangul jamo, or "e" + combining acute
338
+ // from a file authored on another platform) is visually identical to its
339
+ // composed form but byte-different, which failed an otherwise exact context.
340
+ return rustTrim(String(s ?? '').normalize('NFC'))
338
341
  .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-')
339
342
  .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
340
343
  .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
@@ -405,6 +408,41 @@ export function longestCommonSubstringLen(a, b, cap = 4000) {
405
408
  return best;
406
409
  }
407
410
 
411
+ // Total character drift tolerated per context line by the edit-distance tier.
412
+ // Deliberately tiny: 3 context lines buy exactly 1 character of slack, so the
413
+ // tier rescues a typo, never a genuinely different block.
414
+ export const EDIT_DISTANCE_ALLOWANCE_PER_LINE = 0.34;
415
+
416
+ // Levenshtein distance with an early exit: returns the exact distance while it
417
+ // stays within `max`, otherwise `max + 1` as soon as that is certain. The
418
+ // length pre-check and the per-row minimum keep a whole-file scan cheap.
419
+ export function boundedEditDistance(a, b, max) {
420
+ const s = String(a ?? '');
421
+ const t = String(b ?? '');
422
+ if (s === t) return 0;
423
+ if (!(max > 0)) return 1;
424
+ if (Math.abs(s.length - t.length) > max) return max + 1;
425
+ const n = s.length;
426
+ const m = t.length;
427
+ let prev = new Int32Array(m + 1);
428
+ let curr = new Int32Array(m + 1);
429
+ for (let j = 0; j <= m; j++) prev[j] = j;
430
+ for (let i = 1; i <= n; i++) {
431
+ curr[0] = i;
432
+ let rowMin = curr[0];
433
+ const ci = s.charCodeAt(i - 1);
434
+ for (let j = 1; j <= m; j++) {
435
+ const cost = ci === t.charCodeAt(j - 1) ? 0 : 1;
436
+ const value = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
437
+ curr[j] = value;
438
+ if (value < rowMin) rowMin = value;
439
+ }
440
+ if (rowMin > max) return max + 1;
441
+ const tmp = prev; prev = curr; curr = tmp;
442
+ }
443
+ return prev[m] > max ? max + 1 : prev[m];
444
+ }
445
+
408
446
  export function findLineSequence(lines, needle, fromLine, preferredLine = 0, options = {}) {
409
447
  if (!Array.isArray(needle) || needle.length === 0) return Math.max(0, preferredLine || fromLine || 0);
410
448
  const eof = options?.eof === true;
@@ -2,7 +2,7 @@
2
2
  // mode gating, binary resolution, prewarm/idle lifecycle, and the char-indexed
3
3
  // EDIT client. Split out of patch.mjs; behavior is identical.
4
4
  //
5
- // Backend: NATIVE-ONLY. Every supported apply/edit case is dispatched to the
5
+ // Executor: NATIVE-ONLY. Every supported apply/edit case is dispatched to the
6
6
  // mixdog-patch Rust engine via the persistent stdio server. There is NO JS
7
7
  // apply fallback: unsupported / unsafe input returns a clean Error string.
8
8
 
@@ -144,8 +144,8 @@ function restorePatchRollbackState(snapshots, readStateScope) {
144
144
  }
145
145
 
146
146
  // Apply one "wave" (a set of unique-target parsed entries) via the native
147
- // (+ JS out-of-base) split. Returns { backend, text } on success or
148
- // { backend, error } so the caller decides whether earlier waves already
147
+ // (+ JS out-of-base) split. Returns { executor, text } on success or
148
+ // { executor, error } so the caller decides whether earlier waves already
149
149
  // committed to disk. Extracted verbatim from the inline applyWave closure so
150
150
  // both the default wave loop and sequence mode share identical apply
151
151
  // semantics.
@@ -156,7 +156,7 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
156
156
  const parsedInside = (wparsed || []).filter(
157
157
  (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
158
158
  );
159
- const backend = outsideEntries.length > 0
159
+ const executor = outsideEntries.length > 0
160
160
  ? (insideEntries.length > 0 ? 'native+js-patch' : 'js-patch')
161
161
  : 'native-patch';
162
162
  const resultParts = [];
@@ -173,7 +173,7 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
173
173
  signal: abortSignal,
174
174
  parsed: parsedInside,
175
175
  });
176
- if (isPatchErrorText(nativeResult)) return { backend, error: nativeResult };
176
+ if (isPatchErrorText(nativeResult)) return { executor, error: nativeResult };
177
177
  resultParts.push(nativeResult);
178
178
  }
179
179
  if (outsideEntries.length > 0) {
@@ -187,10 +187,10 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
187
187
  fuzzy,
188
188
  readStateScope,
189
189
  });
190
- if (isPatchErrorText(jsResult)) return { backend, error: jsResult };
190
+ if (isPatchErrorText(jsResult)) return { executor, error: jsResult };
191
191
  resultParts.push(jsResult);
192
192
  }
193
- return { backend, text: resultParts.join('\n') };
193
+ return { executor, text: resultParts.join('\n') };
194
194
  }
195
195
 
196
196
  // Default ordered section mode. Apply each file section in listed order,
@@ -384,7 +384,7 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
384
384
  const skipped = [];
385
385
  let failed = null;
386
386
  let failedIndex = -1;
387
- let backend = 'native-patch';
387
+ let executor = 'native-patch';
388
388
  for (let i = 0; i < units.length; i++) {
389
389
  const unit = units[i];
390
390
  if (failed) { skipped.push(unit.displayPath); continue; }
@@ -421,7 +421,7 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
421
421
  }
422
422
  }
423
423
  const res = await applyParsedWave(wave, basePath, waveOpts);
424
- backend = res.backend;
424
+ executor = res.executor;
425
425
  if (res.error) {
426
426
  failed = { displayPath: unit.displayPath, error: res.error };
427
427
  failedIndex = i;
@@ -459,7 +459,7 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
459
459
  }
460
460
  const head = `apply_patch: ${verb} ${units.length} section(s)`;
461
461
  const body = (appliedTexts ? `${head}\n${appliedTexts}` : head) + dryNote + rejectedTail;
462
- return wrapPatchMutationOutput(body, mutationPlan, { backend });
462
+ return wrapPatchMutationOutput(body, mutationPlan, { executor });
463
463
  }
464
464
  const failMsg = failed.error.replace(/^Error:\s*/, '');
465
465
  const rollbackErrors = (!dryRun && rollbackOnFailure)
@@ -499,7 +499,7 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
499
499
  if (skipped.length > 0) {
500
500
  lines.push(`--- skipped (not attempted): ${skipped.join(', ')} ---`);
501
501
  }
502
- return wrapPatchMutationOutput(lines.join('\n') + dryNote + rejectedTail, mutationPlan, { backend });
502
+ return wrapPatchMutationOutput(lines.join('\n') + dryNote + rejectedTail, mutationPlan, { executor });
503
503
  }));
504
504
  }
505
505
 
@@ -703,7 +703,7 @@ export function appendPostPatchExcerpts(outputText, patchStr, requestedFormat, b
703
703
  }
704
704
  }
705
705
 
706
- const APPLY_PATCH_SCHEMA_KEYS = new Set(['patch', 'format', 'base_path', 'dry_run', 'reject_partial', 'fuzzy', 'sequence', 'mode']);
706
+ const APPLY_PATCH_SCHEMA_KEYS = new Set(['patch', 'format', 'base_path', 'root', 'dry_run', 'reject_partial', 'fuzzy', 'sequence', 'mode']);
707
707
  function salvageShatteredV4APatchArgs(args) {
708
708
  if (!args || typeof args !== 'object') return args;
709
709
  const rawPatch = typeof args.patch === 'string' ? args.patch : '';
@@ -723,9 +723,41 @@ function salvageShatteredV4APatchArgs(args) {
723
723
  return cleaned;
724
724
  }
725
725
 
726
+ function extractInlinePatchRoot(patchText) {
727
+ const text = String(patchText || '');
728
+ const match = /^(\*\*\* Begin Patch\r?\n)\*\*\* Root:[ \t]*(.*?)\r?\n/.exec(text);
729
+ if (!match) return { patch: text, root: null };
730
+ const root = String(match[2] || '').trim();
731
+ if (!root) throw new Error('apply_patch: "*** Root:" requires a containing directory');
732
+ return {
733
+ patch: `${match[1]}${text.slice(match[0].length)}`,
734
+ root,
735
+ };
736
+ }
737
+
738
+ function isFilesystemRootSpecifier(value, cwd) {
739
+ const raw = String(value || '').trim();
740
+ if (!raw) return false;
741
+ // Keep Windows semantics testable on every host and reject the ambiguous
742
+ // drive-relative spelling (`C:`) alongside an actual drive root.
743
+ if (/^[A-Za-z]:(?:[\\/]?)$/.test(raw)) return true;
744
+ const resolved = resolveBasePath(cwd, raw);
745
+ return pathDirname(resolved) === resolved;
746
+ }
747
+
726
748
  async function apply_patch(args, cwd, options = {}) {
727
749
  args = salvageShatteredV4APatchArgs(args);
728
- const patchStr = salvageV4AOpening((typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, ''));
750
+ let patchStr = salvageV4AOpening((typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, ''));
751
+ const inlineRoot = extractInlinePatchRoot(patchStr);
752
+ patchStr = inlineRoot.patch;
753
+ if (inlineRoot.root) {
754
+ const argumentRoot = typeof args?.root === 'string' ? args.root.trim() : '';
755
+ if (argumentRoot
756
+ && pathResolve(resolveBasePath(cwd, argumentRoot)) !== pathResolve(resolveBasePath(cwd, inlineRoot.root))) {
757
+ throw new Error('apply_patch: root argument conflicts with the "*** Root:" directive');
758
+ }
759
+ args = { ...(args || {}), root: inlineRoot.root };
760
+ }
729
761
  if (!patchStr.trim()) {
730
762
  throw new Error('apply_patch: "patch" is required (unified diff or V4A patch string)');
731
763
  }
@@ -755,6 +787,41 @@ async function apply_patch(args, cwd, options = {}) {
755
787
  } catch (err) {
756
788
  return `Error: ${err?.message || String(err)}`;
757
789
  }
790
+ // Write-root gate: a patch may only touch paths inside a DECLARED root —
791
+ // the session directory by default, or `root`/`base_path` when the caller
792
+ // names one. Anything outside is refused before a single byte is written, so
793
+ // a mis-scoped working directory can never silently rewrite another tree.
794
+ // Editing outside stays possible, but only as a deliberate act: name the
795
+ // root that contains those targets in the same call.
796
+ const explicitRoot = typeof args?.root === 'string' && args.root.trim() ? args.root.trim() : null;
797
+ if (explicitRoot && isFilesystemRootSpecifier(explicitRoot, cwd)) {
798
+ throw new Error(`apply_patch: refusing filesystem root as write root: ${normalizeOutputPath(explicitRoot)}`);
799
+ }
800
+ const writeRoot = resolveBasePath(cwd, explicitRoot ?? args?.base_path ?? null);
801
+ if (explicitRoot) {
802
+ try {
803
+ await assertPathReachable(writeRoot);
804
+ if (!statSync(writeRoot).isDirectory()) {
805
+ throw new Error(`not a directory — ${normalizeOutputPath(writeRoot)}`);
806
+ }
807
+ } catch (err) {
808
+ throw new Error(`apply_patch: invalid write root ${normalizeOutputPath(writeRoot)}: ${err?.message || String(err)}`);
809
+ }
810
+ }
811
+ const outsideTargets = [];
812
+ for (const rel of patchTargetPaths(patchStr, basePath)) {
813
+ const abs = isAbsolute(rel) ? pathResolve(rel) : pathResolve(basePath, rel);
814
+ if (isResolvedPathOutsideBase(abs, writeRoot)) outsideTargets.push(normalizeOutputPath(abs));
815
+ }
816
+ if (outsideTargets.length > 0) {
817
+ const shown = [...new Set(outsideTargets)];
818
+ const head = shown.slice(0, 3).join(', ');
819
+ const more = shown.length > 3 ? ` (+${shown.length - 3} more)` : '';
820
+ throw new Error(
821
+ `apply_patch: ${shown.length} target(s) fall outside the write root ${normalizeOutputPath(writeRoot)}: ${head}${more}. `
822
+ + 'Check the paths first; if intended, set root (JSON) or add "*** Root: <containing directory>" after "*** Begin Patch" (freeform).',
823
+ );
824
+ }
758
825
  const rejectPartial = args?.reject_partial !== false;
759
826
  const dryRun = args?.dry_run === true;
760
827
  const fuzzy = args?.fuzzy !== false;
@@ -896,17 +963,17 @@ async function apply_patch(args, cwd, options = {}) {
896
963
  if (v4aRenameOnly) {
897
964
  const lines = formatV4ARenameSuccessLines(v4aRenameResults);
898
965
  if (lines.length === 0) return 'Error: patch contained no applicable file sections';
899
- return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { backend: 'v4a-rename' });
966
+ return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { executor: 'v4a-rename' });
900
967
  }
901
968
  // Apply one wave (a set of unique targets) via applyParsedWave (native +
902
- // JS split). Returns { backend, text } on success or { backend, error }.
969
+ // JS split). Returns { executor, text } on success or { executor, error }.
903
970
  const applyWave = (wave) => applyParsedWave(wave, basePath, { fuzz, rejectPartial, dryRun, fuzzy, readStateScope, abortSignal });
904
971
 
905
972
  // Duplicate-target blocks were split into contiguous sequential groups
906
973
  // (listed order preserved); apply them in order, each against the prior
907
974
  // group's on-disk result.
908
975
  const waveTexts = [];
909
- let backend = 'native-patch';
976
+ let executor = 'native-patch';
910
977
  // dry_run never writes, so a later group would validate against unchanged
911
978
  // disk and false-fail on any block that depends on an earlier edit. Only
912
979
  // the first group is validated under dry_run; the rest are reported as
@@ -914,9 +981,9 @@ async function apply_patch(args, cwd, options = {}) {
914
981
  const groupCount = (dryRun && waveDispatch.length > 1) ? 1 : waveDispatch.length;
915
982
  for (let w = 0; w < groupCount; w++) {
916
983
  const res = await applyWave(waveDispatch[w]);
917
- backend = res.backend;
984
+ executor = res.executor;
918
985
  if (res.error) {
919
- if (w === 0) return wrapPatchMutationOutput(res.error, mutationPlan, { backend });
986
+ if (w === 0) return wrapPatchMutationOutput(res.error, mutationPlan, { executor });
920
987
  // A later group failed. rejectPartial makes each group all-or-nothing,
921
988
  // so every block in groups 1..w is fully committed to disk and left in
922
989
  // place. List them all so the caller knows the true on-disk state.
@@ -927,7 +994,7 @@ async function apply_patch(args, cwd, options = {}) {
927
994
  '--- failing block ---',
928
995
  failMsg,
929
996
  ].join('\n');
930
- return wrapPatchMutationOutput(note, mutationPlan, { backend });
997
+ return wrapPatchMutationOutput(note, mutationPlan, { executor });
931
998
  }
932
999
  waveTexts.push(res.text);
933
1000
  }
@@ -949,9 +1016,9 @@ async function apply_patch(args, cwd, options = {}) {
949
1016
  `hunk-level rejected (rejectPartial=false, V4A): ${rejectedV4AHunks.length}`,
950
1017
  ...rejectedV4AHunks.map((r) => ` REJECT ${r.file || '(unknown)'} — ${String(r.reason || '').split(';')[0].trim()}`),
951
1018
  ];
952
- return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { backend });
1019
+ return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { executor });
953
1020
  }
954
- return wrapPatchMutationOutput(combined, mutationPlan, { backend });
1021
+ return wrapPatchMutationOutput(combined, mutationPlan, { executor });
955
1022
  };
956
1023
 
957
1024
  return withBuiltinPathLocks(_lockPaths, () =>
@@ -1051,6 +1118,13 @@ function patchTargetPaths(patchStr, basePath) {
1051
1118
  const rel = m[1].trim();
1052
1119
  if (rel) out.push(rel);
1053
1120
  }
1121
+ // A rename WRITES its destination, so the destination is a target too — the
1122
+ // write-root gate and the replay snapshot both need it.
1123
+ const mre = /^\*\*\* Move to:\s*(.+)$/gm;
1124
+ while ((m = mre.exec(String(patchStr || '')))) {
1125
+ const rel = m[1].trim();
1126
+ if (rel) out.push(rel);
1127
+ }
1054
1128
  const ure = /^\+\+\+ (?:b\/)?(.+)$/gm;
1055
1129
  while ((m = ure.exec(String(patchStr || '')))) {
1056
1130
  const rel = m[1].trim();
@@ -283,6 +283,15 @@ export function parseV4APatch(patchStr) {
283
283
  if (rawLine.startsWith('@@')) {
284
284
  const anchor = normaliseV4AAnchor(rawLine.slice(2));
285
285
  if (currentHunk && currentHunk.lines.length > 0) finishHunk();
286
+ // Consecutive `@@` lines narrow ONE hunk's position (`@@ class Foo`
287
+ // then `@@ def bar`): append to the open hunk's anchor chain instead
288
+ // of replacing the hunk, which dropped every anchor but the last and
289
+ // let the edit resolve against the wrong occurrence.
290
+ if (currentHunk && currentHunk.lines.length === 0) {
291
+ if (anchor) currentHunk.anchors.push(anchor);
292
+ pendingAnchors = [];
293
+ continue;
294
+ }
286
295
  pendingAnchors.push(anchor);
287
296
  currentHunk = { anchors: pendingAnchors.slice(), lines: [] };
288
297
  pendingAnchors = [];
@@ -374,6 +383,13 @@ function parseUnifiedAsV4APatch(patchStr, { label, resolveAnchor }) {
374
383
  if (rawLine.startsWith('@@')) {
375
384
  const anchor = resolveAnchor(rawLine);
376
385
  if (currentHunk && currentHunk.lines.length > 0) finishHunk();
386
+ // Anchor chain (see the V4A parser above): consecutive `@@` lines refine
387
+ // one hunk's position and must all be kept.
388
+ if (currentHunk && currentHunk.lines.length === 0) {
389
+ if (anchor) currentHunk.anchors.push(anchor);
390
+ pendingAnchors = [];
391
+ continue;
392
+ }
377
393
  pendingAnchors.push(anchor);
378
394
  currentHunk = { anchors: pendingAnchors.slice(), lines: [] };
379
395
  pendingAnchors = [];