mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -27,11 +27,12 @@ function shellTypeFor(shell) {
27
27
 
28
28
  function shellSpec(shell, shellType = shellTypeFor(shell)) {
29
29
  if (shellType === 'powershell') {
30
- // `-WindowStyle Hidden` is a Windows-only switch. PowerShell Core
31
- // (pwsh) on macOS/Linux rejects it, so only append it on win32.
32
- const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
33
- if (process.platform === 'win32') psArgs.push('-WindowStyle', 'Hidden');
34
- psArgs.push('-Command');
30
+ // Deliberately NO `-WindowStyle Hidden` here: every spawn site passes
31
+ // windowsHide:true (CREATE_NO_WINDOW), which suppresses the console at
32
+ // the OS level without leaving a token on the command line. The CLI
33
+ // switch is redundant AND matches Defender's node→hidden-PowerShell
34
+ // dropper signature (Trojan:Win32/PowhidSubExec false positive).
35
+ const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'];
35
36
  return {
36
37
  shell,
37
38
  shellArg: '-Command',
@@ -180,13 +180,61 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
180
180
  }
181
181
 
182
182
  if (mode === 'dependents') {
183
- if (!rel) throw new Error('code_graph dependents: "file" is required');
184
- if (!node) return _appendSameBasenameHint(`Error: code_graph dependents: file not found in graph: ${normFile || '(missing file)'}`, normFile, graph);
183
+ let depRel = rel;
184
+ let depNorm = normFile;
185
+ let subNote = null;
186
+ // (1) Symbol inference runs ONLY when no `file` arg was supplied at all —
187
+ // an explicit file (even a directory that yields no rel) is never overridden.
188
+ if (!depRel && !normFile) {
189
+ const symCandidates = [
190
+ ...(Array.isArray(args?.symbols) ? args.symbols : []),
191
+ args?.symbol,
192
+ ].map((s) => String(s || '').trim()).filter(Boolean);
193
+ const KNOWN_SRC_EXT = /\.(mjs|cjs|js|jsx|mts|cts|ts|tsx|json|py|go|rb|rs|java|kt|c|h|cc|cpp|hpp|cs|php|swift|scala|sh)$/i;
194
+ // (2) Symbol lookup FIRST — dotted names (e.g. obj.method) resolve here
195
+ // before any path classification.
196
+ for (const s of symCandidates) {
197
+ const hits = _findSymbolHits(graph, s, {});
198
+ const usable = hits.filter((h) => graph.nodes.get(h.rel));
199
+ const pool = usable.length ? usable : hits;
200
+ if (!pool.length) continue;
201
+ // (3) Deterministic pick: defining hit, else first by sorted rel.
202
+ const sorted = [...pool].sort((a, b) => String(a.rel).localeCompare(String(b.rel)));
203
+ const primary = sorted.find((h) => h.declarationLike) || sorted[0];
204
+ depRel = primary.rel; depNorm = primary.rel;
205
+ subNote = `# note: dependents resolved from symbol '${s}' → ${primary.rel}`;
206
+ const others = [...new Set(sorted.map((h) => h.rel))].filter((r) => r !== primary.rel);
207
+ if (others.length) subNote += `\n# note: '${s}' also defined in: ${others.join(', ')}`;
208
+ break;
209
+ }
210
+ // Path-classification only when the value has a slash or a known source
211
+ // extension — never for plain dotted symbol names.
212
+ if (!depRel) {
213
+ const pathLike = symCandidates.find((s) => /[\\/]/.test(s) || KNOWN_SRC_EXT.test(s));
214
+ if (pathLike) {
215
+ const pAbs = isAbsolute(pathLike) ? pathResolve(pathLike) : pathResolve(cwd, pathLike);
216
+ const pRel = _graphRel(pAbs, cwd);
217
+ if (graph.nodes.get(pRel)) {
218
+ depRel = pRel; depNorm = pathLike;
219
+ subNote = `# note: treated symbol '${pathLike}' as file`;
220
+ }
221
+ }
222
+ }
223
+ // (4) Nothing resolved → actionable hint naming the attempted values,
224
+ // with a distinct message when no symbol was supplied at all.
225
+ if (!depRel) {
226
+ throw new Error(symCandidates.length
227
+ ? `code_graph dependents: dependents needs file:<path>; got symbol only (tried: ${symCandidates.join(', ')})`
228
+ : 'code_graph dependents: "file" is required (no file or symbol supplied)');
229
+ }
230
+ }
231
+ const depFileNode = depRel ? graph.nodes.get(depRel) : null;
232
+ if (!depFileNode) return _appendSameBasenameHint(`Error: code_graph dependents: file not found in graph: ${depNorm || '(missing file)'}`, depNorm, graph);
185
233
  const GRAPH_LIST_CAP = 200;
186
- const depsAll = [...(graph.reverse.get(rel) || [])].sort();
234
+ const depsAll = [...(graph.reverse.get(depRel) || [])].sort();
187
235
  if (!depsAll.length) return '(no dependents)';
188
236
  const deps = depsAll.slice(0, GRAPH_LIST_CAP);
189
- const basename = rel.split('/').pop();
237
+ const basename = depRel.split('/').pop();
190
238
  const stem = basename.replace(/\.[^/.]+$/, '');
191
239
  const enriched = deps.map((dep) => {
192
240
  const depNode = graph.nodes.get(dep);
@@ -203,7 +251,8 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
203
251
  }
204
252
  return dep;
205
253
  });
206
- const out = enriched.join('\n');
254
+ const body = enriched.join('\n');
255
+ const out = subNote ? `${subNote}\n${body}` : body;
207
256
  return depsAll.length > deps.length
208
257
  ? `${out}\n[truncated — showing first ${GRAPH_LIST_CAP} of ${depsAll.length} dependents]`
209
258
  : out;
@@ -17,8 +17,7 @@ import {
17
17
  resolveV4AEntryPath,
18
18
  parsedEntryResolvedPath,
19
19
  isResolvedPathOutsideBase,
20
- assertNoDuplicateParsedModifyTargets,
21
- mergeDuplicateParsedModifyEntries,
20
+ splitParsedModifyWaves,
22
21
  renderParsedUnifiedPatch,
23
22
  rewriteHeaderPaths,
24
23
  preValidateNativeBatch,
@@ -190,17 +189,17 @@ async function apply_patch(args, cwd, options = {}) {
190
189
  if (!v4aRenameOnly && (!Array.isArray(parsed) || parsed.length === 0)) {
191
190
  return 'Error: patch contained no file sections';
192
191
  }
192
+ // Split duplicate modify-target blocks into sequential waves: occurrence i
193
+ // of a path lands in wave i so each duplicate applies against the prior
194
+ // wave's on-disk result. Non-duplicate patches yield exactly one wave, so
195
+ // single-target behavior is unchanged.
196
+ let parsedWaves = v4aRenameOnly ? [] : [parsed];
193
197
  if (!v4aRenameOnly) {
194
198
  try {
195
- assertNoDuplicateParsedModifyTargets(parsed, basePath);
199
+ parsedWaves = splitParsedModifyWaves(parsed, basePath);
196
200
  } catch (err) {
197
201
  return `Error: ${err?.message || String(err)}`;
198
202
  }
199
- const merged = mergeDuplicateParsedModifyEntries(parsed, basePath);
200
- if (merged.changed) {
201
- parsed = merged.parsed;
202
- normalizedPatchStr = renderParsedUnifiedPatch(parsed);
203
- }
204
203
  }
205
204
 
206
205
  if (!v4aRenameOnly) {
@@ -210,18 +209,22 @@ async function apply_patch(args, cwd, options = {}) {
210
209
  return `Error: ${err?.message || String(err)}`;
211
210
  }
212
211
  }
213
- let entries = [];
214
- let headerRewrites = [];
212
+ // Pre-validate each wave independently: a wave only ever holds unique
213
+ // targets, so the native batch's per-file semantics stay intact.
214
+ const waveDispatch = [];
215
215
  if (!v4aRenameOnly) {
216
216
  try {
217
- ({ entries, headerRewrites } = await preValidateNativeBatch(parsed, basePath));
217
+ for (const wparsed of parsedWaves) {
218
+ const { entries, headerRewrites } = await preValidateNativeBatch(wparsed, basePath);
219
+ waveDispatch.push({ parsed: wparsed, entries, headerRewrites });
220
+ }
218
221
  } catch (err) {
219
222
  return `Error: ${err?.message || String(err)}`;
220
223
  }
221
224
  }
222
225
 
223
226
  const _lockPaths = [
224
- ...entries.map((entry) => entry.fullPath),
227
+ ...new Set(waveDispatch.flatMap((wd) => wd.entries.map((entry) => entry.fullPath))),
225
228
  ...(v4aRenamePlan?.renameSections || []).flatMap((section) => {
226
229
  const src = resolveV4AEntryPath(basePath, section.path);
227
230
  const dest = resolveV4AEntryPath(basePath, section.movePath);
@@ -240,56 +243,96 @@ async function apply_patch(args, cwd, options = {}) {
240
243
  if (lines.length === 0) return 'Error: patch contained no applicable file sections';
241
244
  return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { backend: 'v4a-rename' });
242
245
  }
243
- const insideEntries = entries.filter((entry) => !isResolvedPathOutsideBase(entry.fullPath, basePath));
244
- const outsideEntries = entries.filter((entry) => isResolvedPathOutsideBase(entry.fullPath, basePath));
245
- const parsedInside = (parsed || []).filter(
246
- (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
247
- );
248
- const backend = outsideEntries.length > 0
249
- ? (insideEntries.length > 0 ? 'native+js-patch' : 'js-patch')
250
- : 'native-patch';
251
- const resultParts = [];
252
- if (insideEntries.length > 0) {
253
- const nativePatchStr = rewriteHeaderPaths(renderParsedUnifiedPatch(parsedInside), headerRewrites);
254
- const nativeResult = await dispatchNativePatch({
255
- entries: insideEntries,
256
- basePath,
257
- nativePatchStr,
258
- fuzz,
259
- rejectPartial,
260
- dryRun,
261
- readStateScope,
262
- signal: abortSignal,
263
- parsed: parsedInside,
264
- });
265
- if (isPatchErrorText(nativeResult)) {
266
- return wrapPatchMutationOutput(nativeResult, mutationPlan, { backend });
246
+ // Apply one wave (a set of unique targets) via the existing native(+js)
247
+ // split. Returns { backend, text } on success or { backend, error } so the
248
+ // caller can decide whether earlier waves already committed to disk.
249
+ const applyWave = async ({ parsed: wparsed, entries: wentries, headerRewrites: whr }) => {
250
+ const insideEntries = wentries.filter((entry) => !isResolvedPathOutsideBase(entry.fullPath, basePath));
251
+ const outsideEntries = wentries.filter((entry) => isResolvedPathOutsideBase(entry.fullPath, basePath));
252
+ const parsedInside = (wparsed || []).filter(
253
+ (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
254
+ );
255
+ const backend = outsideEntries.length > 0
256
+ ? (insideEntries.length > 0 ? 'native+js-patch' : 'js-patch')
257
+ : 'native-patch';
258
+ const resultParts = [];
259
+ if (insideEntries.length > 0) {
260
+ const nativePatchStr = rewriteHeaderPaths(renderParsedUnifiedPatch(parsedInside), whr);
261
+ const nativeResult = await dispatchNativePatch({
262
+ entries: insideEntries,
263
+ basePath,
264
+ nativePatchStr,
265
+ fuzz,
266
+ rejectPartial,
267
+ dryRun,
268
+ readStateScope,
269
+ signal: abortSignal,
270
+ parsed: parsedInside,
271
+ });
272
+ if (isPatchErrorText(nativeResult)) return { backend, error: nativeResult };
273
+ resultParts.push(nativeResult);
267
274
  }
268
- resultParts.push(nativeResult);
269
- }
270
- if (outsideEntries.length > 0) {
271
- // Out-of-base targets are applied via the JS dispatcher (no base-path
272
- // confinement); write permission is enforced at the hook layer.
273
- const jsResult = await dispatchJsPatchEntries({
274
- rows: outsideEntries,
275
- parsed,
276
- basePath,
277
- dryRun,
278
- fuzzy,
279
- readStateScope,
280
- });
281
- if (isPatchErrorText(jsResult)) {
282
- return wrapPatchMutationOutput(jsResult, mutationPlan, { backend });
275
+ if (outsideEntries.length > 0) {
276
+ // Out-of-base targets are applied via the JS dispatcher (no base-path
277
+ // confinement); write permission is enforced at the hook layer.
278
+ const jsResult = await dispatchJsPatchEntries({
279
+ rows: outsideEntries,
280
+ parsed: wparsed,
281
+ basePath,
282
+ dryRun,
283
+ fuzzy,
284
+ readStateScope,
285
+ });
286
+ if (isPatchErrorText(jsResult)) return { backend, error: jsResult };
287
+ resultParts.push(jsResult);
288
+ }
289
+ return { backend, text: resultParts.join('\n') };
290
+ };
291
+
292
+ // Duplicate-target blocks were split into contiguous sequential groups
293
+ // (listed order preserved); apply them in order, each against the prior
294
+ // group's on-disk result.
295
+ const waveTexts = [];
296
+ let backend = 'native-patch';
297
+ // dry_run never writes, so a later group would validate against unchanged
298
+ // disk and false-fail on any block that depends on an earlier edit. Only
299
+ // the first group is validated under dry_run; the rest are reported as
300
+ // unsimulated below (no false failures).
301
+ const groupCount = (dryRun && waveDispatch.length > 1) ? 1 : waveDispatch.length;
302
+ for (let w = 0; w < groupCount; w++) {
303
+ const res = await applyWave(waveDispatch[w]);
304
+ backend = res.backend;
305
+ if (res.error) {
306
+ if (w === 0) return wrapPatchMutationOutput(res.error, mutationPlan, { backend });
307
+ // A later group failed. rejectPartial makes each group all-or-nothing,
308
+ // so every block in groups 1..w is fully committed to disk and left in
309
+ // place. List them all so the caller knows the true on-disk state.
310
+ const failMsg = res.error.replace(/^Error:\s*/, '');
311
+ const note = [
312
+ `Error: apply_patch: a block failed in sequential group ${w + 1}/${waveDispatch.length}; every edit listed below was already applied to disk (writes committed) and left in place:`,
313
+ waveTexts.join('\n'),
314
+ '--- failing block ---',
315
+ failMsg,
316
+ ].join('\n');
317
+ return wrapPatchMutationOutput(note, mutationPlan, { backend });
283
318
  }
284
- resultParts.push(jsResult);
319
+ waveTexts.push(res.text);
320
+ }
321
+
322
+ let combined = waveTexts.join('\n');
323
+ if (dryRun && waveDispatch.length > 1) {
324
+ const skipped = [...new Set(
325
+ waveDispatch.slice(1).flatMap((wd) => wd.entries.map((e) => e.displayPath)),
326
+ )];
327
+ combined += `\n(dry_run: only the first sequential group was validated against disk; blocks depending on earlier edits were not simulated: ${skipped.join(', ')})`;
285
328
  }
286
- let combined = resultParts.join('\n');
287
329
  const renameLines = formatV4ARenameSuccessLines(v4aRenameResults);
288
330
  if (renameLines.length > 0 && !isPatchErrorText(combined)) {
289
331
  combined = `${renameLines.join('\n')}\n${combined}`;
290
332
  }
291
333
  if (!isPatchErrorText(combined) && options?.toolCallId) {
292
- registerApplyPatchUiDiff(options.toolCallId, rewriteHeaderPaths(normalizedPatchStr, headerRewrites));
334
+ const allRewrites = waveDispatch.flatMap((wd) => wd.headerRewrites);
335
+ registerApplyPatchUiDiff(options.toolCallId, rewriteHeaderPaths(normalizedPatchStr, allRewrites));
293
336
  }
294
337
  if (!isPatchErrorText(combined) && rejectedV4AHunks.length > 0) {
295
338
  const tail = [
@@ -88,42 +88,60 @@ function parsedEntryTargetKey(entry, basePath) {
88
88
  return process.platform === 'win32' ? fullPath.toLowerCase() : fullPath;
89
89
  }
90
90
 
91
- export function mergeDuplicateParsedModifyEntries(parsed, basePath) {
92
- const out = [];
93
- const byTarget = new Map();
94
- let changed = false;
95
- for (const entry of parsed || []) {
96
- const key = parsedEntryTargetKey(entry, basePath);
97
- if (!key) {
98
- out.push(entry);
99
- continue;
100
- }
101
- const existing = byTarget.get(key);
102
- if (!existing) {
103
- byTarget.set(key, entry);
104
- out.push(entry);
105
- continue;
91
+ // Group parsed entries into sequential application "waves". When a file is
92
+ // listed as a modify target N times, occurrence i is placed in wave i, so
93
+ // each duplicate block applies against the on-disk result of the previous
94
+ // one the native engine re-reads the file per apply() call, giving true
95
+ // sequential semantics ("block 2 against block 1's output"). Unique and
96
+ // non-modify entries always land in wave 0.
97
+ //
98
+ // Genuinely unsupported same-path combos (a create/delete mixed with any
99
+ // other block for that path) cannot be sequenced and throw with guidance to
100
+ // merge hunks into one block or send separate apply_patch calls.
101
+ export function splitParsedModifyWaves(parsed, basePath) {
102
+ const entries = Array.isArray(parsed) ? parsed : [];
103
+ const kindsByPath = new Map();
104
+ for (const entry of entries) {
105
+ const kind = classifyEntry(entry);
106
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
107
+ if (!headerName || DEV_NULL.test(headerName)) continue;
108
+ const full = resolveEntryPath(basePath, headerName);
109
+ const key = process.platform === 'win32' ? full.toLowerCase() : full;
110
+ const rec = kindsByPath.get(key) || { kinds: [], headerName };
111
+ rec.kinds.push(kind);
112
+ kindsByPath.set(key, rec);
113
+ }
114
+ for (const { kinds, headerName } of kindsByPath.values()) {
115
+ if (kinds.length > 1 && kinds.some((k) => k !== 'modify')) {
116
+ const display = normalizeOutputPath(stripDiffPrefix(headerName));
117
+ throw new Error(
118
+ `apply_patch: unsupported duplicate target ${display} — a create/delete block cannot `
119
+ + 'be combined with other blocks for the same path. Merge the hunks into one block or '
120
+ + 'send separate apply_patch calls.',
121
+ );
106
122
  }
107
- existing.hunks.push(...(entry.hunks || []));
108
- changed = true;
109
123
  }
110
- return { parsed: out, changed };
111
- }
112
-
113
-
114
- export function assertNoDuplicateParsedModifyTargets(parsed, basePath) {
115
- const seenPaths = new Set();
116
- for (const entry of parsed || []) {
117
- if (classifyEntry(entry) !== 'modify') continue;
124
+ // Split into contiguous sequential groups that PRESERVE the patch's block
125
+ // listing order: accumulate blocks until a target path would repeat within
126
+ // the current group, then start a new group beginning with that block.
127
+ // Every group holds unique targets (the native batch stays valid) and
128
+ // groups apply in listed order, so the effective apply order — and thus the
129
+ // set of committed blocks at any failure point — equals the on-wire order.
130
+ const groups = [];
131
+ let current = [];
132
+ let currentKeys = new Set();
133
+ for (const entry of entries) {
118
134
  const key = parsedEntryTargetKey(entry, basePath);
119
- if (!key) continue;
120
- if (seenPaths.has(key)) {
121
- const headerName = entry.oldFileName || entry.newFileName;
122
- const display = normalizeOutputPath(stripDiffPrefix(headerName));
123
- throw new Error(`apply_patch: duplicate target ${display} — patch lists the same path twice.`);
135
+ if (key && currentKeys.has(key)) {
136
+ groups.push(current);
137
+ current = [];
138
+ currentKeys = new Set();
124
139
  }
125
- seenPaths.add(key);
140
+ current.push(entry);
141
+ if (key) currentKeys.add(key);
126
142
  }
143
+ if (current.length > 0) groups.push(current);
144
+ return groups;
127
145
  }
128
146
 
129
147
  function headerRelFromBase(basePath, absNorm) {
@@ -526,10 +526,19 @@ function readRawBufForV4AConversion(fullPath) {
526
526
  return buf;
527
527
  }
528
528
 
529
+ // win32 filesystems are case-insensitive, so `Foo` and `foo` are the same
530
+ // file: the V4A source-line cache MUST key on this normalized form at every
531
+ // get/set, otherwise a mixed-case duplicate section refreshed under one
532
+ // casing is missed under another and converts against stale/original lines.
533
+ export function v4aLinesCacheKey(fullPath) {
534
+ return process.platform === 'win32' ? String(fullPath).toLowerCase() : String(fullPath);
535
+ }
536
+
529
537
  function v4aConversionSourceLines(fullPath, linesCache) {
530
- if (linesCache.has(fullPath)) return linesCache.get(fullPath);
538
+ const cacheKey = v4aLinesCacheKey(fullPath);
539
+ if (linesCache.has(cacheKey)) return linesCache.get(cacheKey);
531
540
  const lines = splitTextLinesForPatch(readRawBufForV4AConversion(fullPath).toString('utf-8'));
532
- linesCache.set(fullPath, lines);
541
+ linesCache.set(cacheKey, lines);
533
542
  return lines;
534
543
  }
535
544
 
@@ -552,6 +561,20 @@ export async function convertV4ASectionsToUnifiedPatch(sections, basePath, optio
552
561
  const fuzzy = options.fuzzy !== false;
553
562
  const out = [];
554
563
  const v4aLinesCache = new Map();
564
+ // Paths that appear as update targets more than once: their duplicate
565
+ // sections must be converted against the PRIOR section's result so the
566
+ // emitted unified hunks line up for sequential (wave) application. We
567
+ // refresh v4aLinesCache after each such section below.
568
+ const dupUpdatePaths = new Set();
569
+ {
570
+ const seenUpd = new Set();
571
+ for (const s of sections || []) {
572
+ if (!s || s.kind === 'add' || s.kind === 'delete' || typeof s.path !== 'string' || !s.path) continue;
573
+ const fp = resolveV4AEntryPath(basePath, s.path);
574
+ const key = v4aLinesCacheKey(fp);
575
+ if (seenUpd.has(key)) dupUpdatePaths.add(key); else seenUpd.add(key);
576
+ }
577
+ }
555
578
  for (const section of sections) {
556
579
  const displayPath = section.path.replace(/\\/g, '/');
557
580
  if (section.kind === 'add') {
@@ -646,6 +669,16 @@ export async function convertV4ASectionsToUnifiedPatch(sections, basePath, optio
646
669
  out.push(`+++ b/${displayPath}`);
647
670
  for (const line of sectionHunks) out.push(line);
648
671
  }
672
+ // If this path is edited again later, the next section must resolve
673
+ // against this section's applied result, not the original file — apply
674
+ // these hunks to the cached lines so duplicate V4A blocks convert to a
675
+ // sequentially-appliable unified patch. Best-effort: on any mismatch we
676
+ // keep the original cache and let native wave application surface it.
677
+ if (dupUpdatePaths.has(v4aLinesCacheKey(fullPath))) {
678
+ try {
679
+ v4aLinesCache.set(v4aLinesCacheKey(fullPath), applyV4AHunksToLines(sourceLines, section.hunks, { fuzzy }));
680
+ } catch { /* leave original cached lines */ }
681
+ }
649
682
  }
650
683
  return out.join('\n') + '\n';
651
684
  }