mixdog 0.9.95 → 0.9.96

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 (178) 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/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -140,8 +140,8 @@ export function killShellJob(jobId) {
140
140
  // wrapper publishing the done marker. Evidence order:
141
141
  // 1. exit file — the wrapper writes exit BEFORE done, so a kill in that
142
142
  // window still left the real exit code; recover it instead of guessing.
143
- // 2. guardian receipt — the child-guardian records why it force-killed the
144
- // tree (host memory floor / orphaned parent) before killing.
143
+ // 2. guardian receipt — the child-guardian records an orphaned parent before
144
+ // cleaning up the owned tree.
145
145
  // 3. otherwise — honest unknown: terminal 'failed' with exitCode null and
146
146
  // an explicit "exit status unknown" error; spilled output is preserved
147
147
  // and the completion notification carries its tail.
@@ -165,12 +165,7 @@ function finalizeMarkerlessShellJob(detail) {
165
165
  } catch { /* no guardian receipt */ }
166
166
  detail.status = 'failed';
167
167
  detail.exitCode = 137;
168
- if (receipt?.reason === 'host-memory-floor') {
169
- const freeMb = Math.round(Number(receipt.freeBytes || 0) / (1024 * 1024));
170
- const floorMb = Math.round(Number(receipt.minFreeMemoryBytes || 0) / (1024 * 1024));
171
- detail.killedByGuardian = true;
172
- detail.error = `killed by child guardian: host free memory ${freeMb} MB fell below the ${floorMb} MB floor — job tree force-killed to protect the host; retry with lower concurrency or after memory recovers`;
173
- } else if (receipt?.reason === 'parent-exit') {
168
+ if (receipt?.reason === 'parent-exit') {
174
169
  detail.killedByGuardian = true;
175
170
  detail.error = 'killed by child guardian: owning runtime process exited (session cleanup)';
176
171
  } else {
@@ -66,10 +66,40 @@ function firstExistingPathFromPath(commandName, excludeRe = null) {
66
66
  .find((candidate) => !excludeRe || !excludeRe.test(candidate)) || null;
67
67
  }
68
68
 
69
+ // Well-known pwsh (PowerShell 7+) install roots, probed on the filesystem when
70
+ // PATH does not carry pwsh.exe. A long-lived daemon frequently inherits an env
71
+ // without the PowerShell 7 directory, which silently downgraded every command
72
+ // to Windows PowerShell 5.1 — the shell that lacks `&&` and drives the bashism
73
+ // preflight blocks. Newest major first, then the Store alias.
74
+ function pwshFromKnownWindowsRoots() {
75
+ const env = process.env;
76
+ const bases = [env.ProgramW6432, env.ProgramFiles, env['ProgramFiles(x86)'], 'C:\\Program Files', 'C:\\Program Files (x86)']
77
+ .filter(Boolean)
78
+ .map((base) => join(base, 'PowerShell'));
79
+ const versions = ['7', '7-preview', '8', '9'];
80
+ for (const base of bases) {
81
+ for (const version of versions) {
82
+ const candidate = join(base, version, 'pwsh.exe');
83
+ if (existsSync(candidate)) return candidate;
84
+ }
85
+ }
86
+ if (env.LOCALAPPDATA) {
87
+ const storeAlias = join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'pwsh.exe');
88
+ if (existsSync(storeAlias)) return storeAlias;
89
+ }
90
+ return null;
91
+ }
92
+
69
93
  function resolveWindowsPowerShell() {
94
+ // Priority: pwsh on PATH → pwsh in a known install root → bundled 5.1 →
95
+ // any powershell.exe on PATH. PowerShell 7 is preferred wherever it exists
96
+ // because 5.1 rejects `&&` chains and several modern operators.
70
97
  const pwsh = firstExistingPathFromPath('pwsh.exe');
71
98
  if (pwsh) return shellSpec(pwsh, 'powershell');
72
99
 
100
+ const installed = pwshFromKnownWindowsRoots();
101
+ if (installed) return shellSpec(installed, 'powershell');
102
+
73
103
  const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
74
104
  const bundled = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
75
105
  if (existsSync(bundled)) return shellSpec(bundled, 'powershell');
@@ -146,9 +146,9 @@ export async function _runDiskCodeGraphFastPath({
146
146
  signal = null,
147
147
  loadDiskEntry,
148
148
  consumeDirty = () => {},
149
- // Long-lived build slot: 'build' lane so a cold graph build never blocks
150
- // short rg spawns behind the win32 cap=1 default lane.
151
- acquireSlot = (signal) => acquireChildSpawnSlot(signal, 'build'),
149
+ // Long-lived code-graph lane so a cold graph build never blocks short rg
150
+ // processes in the independent search lane.
151
+ acquireSlot = (signal) => acquireChildSpawnSlot(signal, 'code-graph'),
152
152
  validateDiskHit = _validateDiskCodeGraphHit,
153
153
  spawnWorker,
154
154
  maxFiles = CODE_GRAPH_MAX_FILES,
@@ -344,7 +344,7 @@ export async function buildCodeGraphAsync(cwd, signal = null, {
344
344
  }
345
345
  // Non-competing prewarm: the signature-validation manifest also needs a
346
346
  // child-spawn slot, so warmers skip before either it or a Worker can queue.
347
- if (bestEffort && !childSpawnHasSpareCapacity()) return null;
347
+ if (bestEffort && !childSpawnHasSpareCapacity('code-graph')) return null;
348
348
  const _genAtStart = _getCodeGraphGen(graphCwd);
349
349
  const promise = (async () => {
350
350
  // Loading the compact disk manifest/one candidate entry is synchronous but
@@ -422,7 +422,7 @@ export function _spawnCodeGraphWorker(
422
422
  if (val instanceof Error) reject(val);
423
423
  else resolve(val);
424
424
  };
425
- (preAcquiredRelease ? Promise.resolve(preAcquiredRelease) : acquireChildSpawnSlot(signal || null, 'build')).then((release) => {
425
+ (preAcquiredRelease ? Promise.resolve(preAcquiredRelease) : acquireChildSpawnSlot(signal || null, 'code-graph')).then((release) => {
426
426
  _releaseSlot = release;
427
427
  if (settled) { release(); _releaseSlot = null; return; }
428
428
  if (signal?.aborted) { settle(new Error('aborted')); return; }
@@ -51,7 +51,8 @@ import {
51
51
 
52
52
  const CODE_GRAPH_BATCHABLE_MODES = new Set(['symbol', 'find_symbol', 'symbol_search', 'callers', 'callees', 'references']);
53
53
  const CODE_GRAPH_FILE_BATCHABLE_MODES = new Set(['imports', 'dependents', 'related', 'impact', 'symbols', 'overview']);
54
- const CODE_GRAPH_SYMBOL_BATCH_CAP = 20;
54
+ const CODE_GRAPH_MAX_BATCH_SYMBOLS = 20;
55
+ const CODE_GRAPH_FILE_BATCH_CAP = 20;
55
56
 
56
57
  function _collectGraphSymbolList(args) {
57
58
  const split = (s) => String(s || '').split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
@@ -60,18 +61,21 @@ function _collectGraphSymbolList(args) {
60
61
  ...(typeof args?.symbols === 'string' ? split(args.symbols) : []),
61
62
  ...(typeof args?.symbol === 'string' ? split(args.symbol) : []),
62
63
  ])];
63
- if (list.length <= CODE_GRAPH_SYMBOL_BATCH_CAP) return list;
64
- const capped = list.slice(0, CODE_GRAPH_SYMBOL_BATCH_CAP);
65
- capped._omitted = list.slice(CODE_GRAPH_SYMBOL_BATCH_CAP);
66
- return capped;
64
+ return list;
67
65
  }
68
66
 
69
- const CODE_GRAPH_FILE_BATCH_CAP = 20;
70
67
  const _AGGREGATE_FILE_WILDCARD_RE = /[*?[\]{}]/;
71
68
  const ROOT_FEDERATED_MODES = new Set([
72
69
  'overview', 'symbol', 'find_symbol', 'symbol_search', 'search',
73
70
  'references', 'callers', 'callees', 'symbols', 'prewarm',
74
71
  ]);
72
+ // Fan-out cap when federation targets are DISCOVERED (immediate child project
73
+ // roots of a sentinel-free cwd) rather than registered. Bounds the cost of
74
+ // answering a query at a multi-repo parent; registered roots are unaffected.
75
+ const CODE_GRAPH_DISCOVERED_FEDERATION_CAP = (() => {
76
+ const raw = parseInt(process.env.MIXDOG_CODE_GRAPH_FEDERATION_CAP ?? '', 10);
77
+ return Number.isFinite(raw) && raw > 0 ? raw : 8;
78
+ })();
75
79
 
76
80
  export async function _runCodeGraphFederation(roots, runOne, projectArgs) {
77
81
  return Promise.all((roots || []).map(async (root) => {
@@ -132,6 +136,25 @@ function _hasAggregateFileArgs(args) {
132
136
  || (typeof args?.files === 'string' && args.files.trim());
133
137
  }
134
138
 
139
+ // Aggregate anchors that ALL resolve to the cwd itself ('.', './', the cwd
140
+ // path) add no scope — they mean "search here". Detected so the call can take
141
+ // the plain-cwd route, which adopts a sentinel-free single tree (a vendored
142
+ // reference checkout) as its own root while still refusing an unbounded or
143
+ // multi-project parent. Without this, `files:"."` turned every reference tree
144
+ // into a hard "not inside a project" refusal.
145
+ function _aggregateAnchorsAreCwd(args, baseCwd) {
146
+ if (!_hasAggregateFileArgs(args)) return false;
147
+ const files = _collectGraphFileList(args);
148
+ if (files.length === 0) return false;
149
+ return files.every((file) => {
150
+ const trimmed = String(file || '').trim();
151
+ if (!trimmed || _AGGREGATE_FILE_WILDCARD_RE.test(trimmed)) return false;
152
+ try {
153
+ return pathResolve(isAbsolute(trimmed) ? trimmed : pathResolve(baseCwd, trimmed)) === pathResolve(baseCwd);
154
+ } catch { return false; }
155
+ });
156
+ }
157
+
135
158
  // An invalid caller cwd may be recovered for an explicit files aggregate only
136
159
  // when every supplied anchor points at the same detectable project. Do not use
137
160
  // the batch cap here: an omitted anchor could belong to another project.
@@ -156,9 +179,35 @@ function _resolveAggregateFileProjectRoot(args, baseCwd) {
156
179
  const root = isDirectory ? _findDirProjectRoot(abs) : _resolveFileProjectRoot(abs);
157
180
  if (!root) return null;
158
181
  roots.add(pathResolve(root));
159
- if (roots.size > 1) return null;
160
182
  }
161
- return roots.size === 1 ? [...roots][0] : null;
183
+ if (roots.size === 0) return null;
184
+ if (roots.size === 1) return [...roots][0];
185
+ // Monorepo anchors legitimately resolve to DIFFERENT sentinels: a workspace
186
+ // package (apps/desktop/package.json) is nearer than the repo root, so one
187
+ // call spanning `apps/desktop/...` and `src/...` yields two roots even though
188
+ // both live in exactly one project. When one candidate contains every other,
189
+ // that outermost root IS the single detectable project — adopt it instead of
190
+ // refusing. Genuinely unrelated trees share no such candidate and still fail.
191
+ return _outermostContainingRoot([...roots]);
192
+ }
193
+
194
+ // The candidate that contains (or equals) every other candidate, else null.
195
+ // Only an EXISTING candidate can win: a bare common ancestor that has no
196
+ // sentinel of its own is never adopted as a project root.
197
+ function _outermostContainingRoot(roots) {
198
+ for (const candidate of roots) {
199
+ if (roots.every((root) => _isSameOrInside(root, candidate))) return candidate;
200
+ }
201
+ return null;
202
+ }
203
+
204
+ function _isSameOrInside(child, parent) {
205
+ try {
206
+ const rel = pathRelative(pathResolve(parent), pathResolve(child));
207
+ return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel));
208
+ } catch {
209
+ return false;
210
+ }
162
211
  }
163
212
 
164
213
  // Aggregate recovery resolves relative anchors against the caller's original
@@ -624,6 +673,11 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
624
673
  args = { ...args };
625
674
  delete args.file;
626
675
  }
676
+ if (symbolMode && _aggregateAnchorsAreCwd(args, baseCwd)) {
677
+ args = { ...args };
678
+ delete args.files;
679
+ delete args.file;
680
+ }
627
681
  const fileArg = (args && typeof args.file === 'string' && args.file.trim()) ? args.file.trim() : '';
628
682
  const hasAggregateFileArgs = _hasAggregateFileArgs(args);
629
683
  let effectiveCwd = baseCwd;
@@ -647,6 +701,20 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
647
701
  ? []
648
702
  : collectTrustedCodeGraphRoots(baseCwd)
649
703
  .filter((root) => pathResolve(root) !== pathResolve(baseCwd));
704
+ // Trust registration is a ROUTING PREFERENCE, not an admission gate. A
705
+ // sentinel-free cwd whose children are obvious project roots (a refs/ folder
706
+ // of checkouts, a multi-repo parent) is answerable: federate over those
707
+ // children instead of refusing the call. Cost stays bounded by the existing
708
+ // per-project graph timeout and the federation fan-out cap — the same way the
709
+ // reference CLIs bound a wide search (time/output caps, never a scope
710
+ // refusal). Registered roots keep priority; discovered children only fill in
711
+ // when registration yields nothing.
712
+ const federationRootsAtBase = trustedRootsAtBase.length
713
+ ? trustedRootsAtBase
714
+ : (baseProjectRoot
715
+ ? []
716
+ : _childProjectRoots(baseCwd, { cap: CODE_GRAPH_DISCOVERED_FEDERATION_CAP })
717
+ .filter((root) => pathResolve(root) !== pathResolve(baseCwd)));
650
718
  // A sentinel-free cwd (multi-repo parent, vendored reference tree) is still
651
719
  // routable when trusted project roots live under it — federate over those
652
720
  // instead of refusing the call outright.
@@ -656,9 +724,12 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
656
724
  && !fileArg
657
725
  && !hasAggregateFileArgs
658
726
  && ROOT_FEDERATED_MODES.has(rawModeAtBase)
659
- && trustedRootsAtBase.length > 0;
727
+ && federationRootsAtBase.length > 0;
660
728
  if (filesystemRootCwd || parentDotFederation || sentinelFreeFederation) {
661
- const trustedRoots = trustedRootsAtBase;
729
+ // A filesystem root stays on the REGISTERED set only: fanning out over
730
+ // every project directory on a whole drive is a different cost class than
731
+ // fanning out over one folder's children.
732
+ const trustedRoots = filesystemRootCwd ? trustedRootsAtBase : federationRootsAtBase;
662
733
  const files = exactDotFederation ? [] : aggregateFilesAtBase;
663
734
  const rawMode = rawModeAtBase;
664
735
  const canFederate = files.length > 0 || ROOT_FEDERATED_MODES.has(rawMode);
@@ -818,7 +889,8 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
818
889
  ? findSymbolTool(_stripEmptyArgs(a), effectiveCwd, signal, options)
819
890
  : codeGraph(a, effectiveCwd, signal, options));
820
891
  if (CODE_GRAPH_BATCHABLE_MODES.has(batchMode)) {
821
- const symbolList = _collectGraphSymbolList(args);
892
+ const collectedSymbols = _collectGraphSymbolList(args);
893
+ const symbolList = collectedSymbols.slice(0, CODE_GRAPH_MAX_BATCH_SYMBOLS);
822
894
  if (symbolList.length > 1) {
823
895
  return (async () => {
824
896
  // Concurrent fan-out: the underlying graph build is single-flight
@@ -831,10 +903,10 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
831
903
  catch (e) { body = `Error: ${e?.message || String(e)}`; }
832
904
  return `# ${batchMode} ${sym}\n${body}`;
833
905
  }));
834
- if (symbolList._omitted?.length) {
906
+ if (collectedSymbols.length > symbolList.length) {
835
907
  sections.push(
836
- `Note: symbol list capped at ${CODE_GRAPH_SYMBOL_BATCH_CAP}; `
837
- + `retry symbols=${JSON.stringify(symbolList._omitted)}.`,
908
+ `[arg-guard] notice: symbol list capped at ${CODE_GRAPH_MAX_BATCH_SYMBOLS} `
909
+ + `(${collectedSymbols.length - symbolList.length} omitted)`,
838
910
  );
839
911
  }
840
912
  return sections.join('\n\n');
@@ -847,8 +919,8 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
847
919
  if (CODE_GRAPH_FILE_BATCHABLE_MODES.has(batchMode)) {
848
920
  const fileList = _collectGraphFileList(args);
849
921
  if (fileList.length > 1) {
850
- const capped = fileList._capped;
851
922
  return (async () => {
923
+ const capped = fileList._capped;
852
924
  const sections = await Promise.all(fileList.map(async (f) => {
853
925
  let body;
854
926
  try { body = await dispatchOne({ ...args, file: f, files: undefined }); }
@@ -269,21 +269,12 @@ export async function _prewarmReferenceSourceText(graph, symbol, language) {
269
269
  }
270
270
  if (uncached.length === 0) return candidateNodes;
271
271
  const { readFile } = await import('fs/promises');
272
- const concurrency = 64;
273
- let next = 0;
274
- async function worker() {
275
- while (true) {
276
- const index = next++;
277
- if (index >= uncached.length) return;
278
- const node = uncached[index];
279
- try {
280
- const text = await readFile(node.abs, 'utf8');
281
- graph._sourceTextCache?.set(node.rel, { fingerprint: node.fingerprint || '', text });
282
- } catch { /* skip unreadable file */ }
283
- }
284
- }
285
- const workerCount = Math.min(Math.max(1, concurrency), uncached.length);
286
- await Promise.all(Array.from({ length: workerCount }, () => worker()));
272
+ await Promise.all(uncached.map(async (node) => {
273
+ try {
274
+ const text = await readFile(node.abs, 'utf8');
275
+ graph._sourceTextCache?.set(node.rel, { fingerprint: node.fingerprint || '', text });
276
+ } catch { /* skip unreadable file */ }
277
+ }));
287
278
  return candidateNodes;
288
279
  }
289
280
 
@@ -3,13 +3,13 @@ export const CODE_GRAPH_TOOL_DEFS = [
3
3
  name: 'code_graph',
4
4
  title: 'Code Graph',
5
5
  annotations: { title: 'Code Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false, compressibleLossless: true },
6
- description: 'Repo code structure/flow over source files. File modes take files[]; symbol modes take symbols[] — exact identifiers via find_symbol/references/callers/callees, keywords via symbol_search/search. Unsupported target arrays are omitted, never mixed.',
6
+ description: 'Source-file structure, symbol relations, and flow. File modes take files[]; symbol modes take symbols[] — exact identifiers via find_symbol/references/callers/callees, keywords via symbol_search/search (symbol-index terms). Unsupported target arrays are omitted, never mixed.',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {
10
10
  mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'], description: 'File modes={overview,imports,dependents,related,impact}; symbols with files[]=file outline; the rest are symbol modes.' },
11
11
  files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file path(s); supported targets only.' },
12
- symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Exact identifiers or keywords; batch multiple in one symbols[].' },
12
+ symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Exact identifiers or keywords; keywords are symbol-index terms; batch multiple in one symbols[].' },
13
13
  body: { type: 'boolean', description: 'Include body.' },
14
14
  limit: { type: 'number', minimum: 1, description: 'Max results.' },
15
15
  depth: { type: 'number', minimum: 1, maximum: 5, description: 'Caller depth.' },
@@ -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 backend 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_ENGINE_DAEMON_HOST !== '1' && env?.MIXDOG_CHANNEL_DAEMON !== '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;
@@ -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 : '';
@@ -755,6 +755,27 @@ async function apply_patch(args, cwd, options = {}) {
755
755
  } catch (err) {
756
756
  return `Error: ${err?.message || String(err)}`;
757
757
  }
758
+ // Write-root gate: a patch may only touch paths inside a DECLARED root —
759
+ // the session directory by default, or `root`/`base_path` when the caller
760
+ // names one. Anything outside is refused before a single byte is written, so
761
+ // a mis-scoped working directory can never silently rewrite another tree.
762
+ // Editing outside stays possible, but only as a deliberate act: name the
763
+ // root that contains those targets in the same call.
764
+ const writeRoot = resolveBasePath(cwd, args?.root ?? args?.base_path ?? null);
765
+ const outsideTargets = [];
766
+ for (const rel of patchTargetPaths(patchStr, basePath)) {
767
+ const abs = isAbsolute(rel) ? pathResolve(rel) : pathResolve(basePath, rel);
768
+ if (isResolvedPathOutsideBase(abs, writeRoot)) outsideTargets.push(normalizeOutputPath(abs));
769
+ }
770
+ if (outsideTargets.length > 0) {
771
+ const shown = [...new Set(outsideTargets)];
772
+ const head = shown.slice(0, 3).join(', ');
773
+ const more = shown.length > 3 ? ` (+${shown.length - 3} more)` : '';
774
+ throw new Error(
775
+ `apply_patch: ${shown.length} target(s) fall outside the write root ${normalizeOutputPath(writeRoot)}: ${head}${more}. `
776
+ + 'Check the paths first; if they are intended, re-issue the same patch with root set to the directory that contains them.',
777
+ );
778
+ }
758
779
  const rejectPartial = args?.reject_partial !== false;
759
780
  const dryRun = args?.dry_run === true;
760
781
  const fuzzy = args?.fuzzy !== false;
@@ -1051,6 +1072,13 @@ function patchTargetPaths(patchStr, basePath) {
1051
1072
  const rel = m[1].trim();
1052
1073
  if (rel) out.push(rel);
1053
1074
  }
1075
+ // A rename WRITES its destination, so the destination is a target too — the
1076
+ // write-root gate and the replay snapshot both need it.
1077
+ const mre = /^\*\*\* Move to:\s*(.+)$/gm;
1078
+ while ((m = mre.exec(String(patchStr || '')))) {
1079
+ const rel = m[1].trim();
1080
+ if (rel) out.push(rel);
1081
+ }
1054
1082
  const ure = /^\+\+\+ (?:b\/)?(.+)$/gm;
1055
1083
  while ((m = ure.exec(String(patchStr || '')))) {
1056
1084
  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 = [];