mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -8,7 +8,7 @@ import {
8
8
  readdirSync, readFileSync, statSync, existsSync, mkdirSync, renameSync, unlinkSync, openSync, readSync, closeSync,
9
9
  } from 'node:fs';
10
10
  import { getPluginData } from '../../config.mjs';
11
- import { writeJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
11
+ import { writeJsonAtomicSync, withFileLockSync } from '../../../../shared/atomic-file.mjs';
12
12
  import {
13
13
  canonicalGraphCwd as _canonicalGraphCwd,
14
14
  registerCodeGraphDrain,
@@ -264,60 +264,63 @@ export function _persistDiskCodeGraphCacheNow({
264
264
  writeJson = writeJsonAtomicSync,
265
265
  } = {}) {
266
266
  try {
267
- _loadDiskCodeGraphCache();
268
- _pruneDiskCodeGraphEntries();
269
267
  const dir = _codeGraphDiskDir();
270
268
  mkdirSync(dir, { recursive: true });
269
+ // Graph BUILD work remains fully parallel. Only the short disk COMMIT is
270
+ // serialized across workers/processes so each writer re-reads and merges
271
+ // the latest manifest while holding one common lock. Per-file/manifest
272
+ // atomic writers run lock:false inside this outer critical section.
273
+ //
274
+ // Main/TUI debounced writes stay try-once (never Atomics.wait on the event
275
+ // loop). Worker strict drains may wait because they run off-thread and must
276
+ // not fail a successful graph build merely because a sibling root finished
277
+ // at the same instant.
278
+ withFileLockSync(join(dir, '.persist.lock'), () => {
279
+ _loadDiskCodeGraphCache();
280
+ _pruneDiskCodeGraphEntries();
281
+
282
+ // Read under the common lock so concurrently completed roots cannot
283
+ // overwrite one another with manifests based on the same stale snapshot.
284
+ let preserved = {};
285
+ try {
286
+ const raw = readFileSync(join(dir, 'manifest.json'), 'utf8');
287
+ const parsed = JSON.parse(raw);
288
+ if (parsed && typeof parsed === 'object') preserved = parsed;
289
+ } catch { /* no existing manifest yet */ }
290
+
291
+ let manifest = { ...preserved };
292
+ const validHashes = new Set();
293
+ for (const [cwd, entry] of _diskCodeGraphCache) {
294
+ const hash = _hashCwd(cwd);
295
+ const file = join(dir, `${hash}.json`);
296
+ writeJson(file, entry, { compact: true, lock: false });
297
+ let bytes = null;
298
+ try { bytes = statSync(file).size; } catch { /* written entry may be unavailable */ }
299
+ manifest[cwd] = {
300
+ hash,
301
+ builtAt: entry.builtAt || Date.now(),
302
+ bytes: Number.isFinite(bytes) ? bytes : undefined,
303
+ maxFiles: Number.isFinite(entry.maxFiles) ? entry.maxFiles : undefined,
304
+ };
305
+ }
306
+ const pruned = _pruneCodeGraphManifestForBudget(manifest, dir);
307
+ manifest = pruned.manifest;
308
+ for (const row of pruned.evicted) {
309
+ _diskCodeGraphCache.delete(row.cwd);
310
+ }
311
+ for (const meta of Object.values(manifest)) {
312
+ if (meta && typeof meta === 'object' && meta.hash) validHashes.add(meta.hash);
313
+ }
271
314
 
272
- // Read the on-disk manifest BEFORE writing so cwd entries owned by
273
- // other instances (MIXDOG_MULTI_INSTANCE=1) survive. Without this,
274
- // our orphan sweep below would happily unlink another instance's
275
- // per-cwd files just because we don't have them in our in-memory map.
276
- let preserved = {};
277
- try {
278
- const raw = readFileSync(join(dir, 'manifest.json'), 'utf8');
279
- const parsed = JSON.parse(raw);
280
- if (parsed && typeof parsed === 'object') preserved = parsed;
281
- } catch { /* no existing manifest yet */ }
282
-
283
- let manifest = { ...preserved };
284
- const validHashes = new Set();
285
- for (const [cwd, entry] of _diskCodeGraphCache) {
286
- const hash = _hashCwd(cwd);
287
- const file = join(dir, `${hash}.json`);
288
- // Try-once (timeoutMs:0): the debounced flush runs on the lead/TUI main
289
- // process (codeGraph tool + prewarm). withFileLockSync's Atomics.wait
290
- // would freeze the renderer on cross-process cache contention. Cache is
291
- // rebuildable — a busy lock just skips this flush; _scheduleDiskCodeGraphCacheFlush
292
- // re-schedules on the next build. Exit drain still runs (unref'd timer
293
- // cancelled + direct call).
294
- writeJson(file, entry, { compact: true, lock: true, timeoutMs: 0 });
295
- let bytes = null;
296
- try { bytes = statSync(file).size; } catch { /* written entry may be unavailable */ }
297
- manifest[cwd] = {
298
- hash,
299
- builtAt: entry.builtAt || Date.now(),
300
- bytes: Number.isFinite(bytes) ? bytes : undefined,
301
- maxFiles: Number.isFinite(entry.maxFiles) ? entry.maxFiles : undefined,
302
- };
303
- }
304
- const pruned = _pruneCodeGraphManifestForBudget(manifest, dir);
305
- manifest = pruned.manifest;
306
- for (const row of pruned.evicted) {
307
- _diskCodeGraphCache.delete(row.cwd);
308
- }
309
- for (const meta of Object.values(manifest)) {
310
- if (meta && typeof meta === 'object' && meta.hash) validHashes.add(meta.hash);
311
- }
312
-
313
- const manifestFile = join(dir, 'manifest.json');
314
- writeJson(manifestFile, manifest, { compact: true, lock: true, timeoutMs: 0 });
315
- _diskManifest = manifest;
315
+ const manifestFile = join(dir, 'manifest.json');
316
+ writeJson(manifestFile, manifest, { compact: true, lock: false });
317
+ _diskManifest = manifest;
316
318
 
317
- // Sweep orphan per-cwd files. validHashes now includes every hash in
318
- // the merged manifest (preserved + ours) so cross-instance cache files
319
- // are never collateral damage.
320
- _sweepCodeGraphCacheDir(dir, validHashes, { sweepJson: true });
319
+ // Sweep orphan per-cwd files. validHashes now includes every hash in
320
+ // the merged manifest (preserved + ours) so cross-instance cache files
321
+ // are never collateral damage.
322
+ _sweepCodeGraphCacheDir(dir, validHashes, { sweepJson: true });
323
+ }, { timeoutMs: strict ? 30_000 : 0 });
321
324
  } catch (err) {
322
325
  process.stderr.write(`[code-graph] disk cache persist failed (target: ${_codeGraphDiskDir()}): ${err?.message || err}\n`);
323
326
  if (strict) throw err;
@@ -62,6 +62,15 @@ const ROOT_FEDERATED_MODES = new Set([
62
62
  'references', 'callers', 'callees', 'symbols', 'prewarm',
63
63
  ]);
64
64
 
65
+ export async function _runCodeGraphFederation(roots, runOne, projectArgs) {
66
+ return Promise.all((roots || []).map(async (root) => {
67
+ let body;
68
+ try { body = await runOne(root, projectArgs); }
69
+ catch (err) { body = `Error: ${err?.message || String(err)}`; }
70
+ return `# project ${formatFederatedProjectLabel(root)}\n${body}`;
71
+ }));
72
+ }
73
+
65
74
  // Absorb: file/files arriving as a JSON-stringified array
66
75
  // (file:"[\"a.mjs\",\"b.mjs\"]") — parse to a real array so the graph lookup
67
76
  // batches per file instead of treating the JSON text as one (missing) path.
@@ -649,8 +658,7 @@ export async function executeCodeGraphTool(name, args, cwd, signal = null, optio
649
658
  }
650
659
  const projectArgs = { ...args };
651
660
  delete projectArgs.cwd;
652
- const sections = [];
653
- for (const { file, abs, root } of routed) {
661
+ const sections = await Promise.all(routed.map(async ({ file, abs, root }) => {
654
662
  const excludedProjectRoots = trustedRoots.filter((candidate) => candidate !== root
655
663
  && owningTrustedCodeGraphRoot(candidate, [root]) === root);
656
664
  let body;
@@ -665,8 +673,8 @@ export async function executeCodeGraphTool(name, args, cwd, signal = null, optio
665
673
  } catch (err) {
666
674
  body = `Error: ${err?.message || String(err)}`;
667
675
  }
668
- sections.push(`# ${rawMode} ${file}\n# project ${formatFederatedProjectLabel(root)}\n${body}`);
669
- }
676
+ return `# ${rawMode} ${file}\n# project ${formatFederatedProjectLabel(root)}\n${body}`;
677
+ }));
670
678
  return sections.join('\n\n');
671
679
  }
672
680
  if (trustedRoots.length && canFederate) {
@@ -687,17 +695,8 @@ export async function executeCodeGraphTool(name, args, cwd, signal = null, optio
687
695
  && owningTrustedCodeGraphRoot(candidate, [root]) === root),
688
696
  },
689
697
  );
690
- let federationWork;
691
- federationWork = (async () => {
692
- const sections = [];
693
- for (const root of trustedRoots) {
694
- let body;
695
- try { body = await runOne(root, projectArgs); }
696
- catch (err) { body = `Error: ${err?.message || String(err)}`; }
697
- sections.push(`# project ${formatFederatedProjectLabel(root)}\n${body}`);
698
- }
699
- return sections.join('\n\n');
700
- })();
698
+ const federationWork = _runCodeGraphFederation(trustedRoots, runOne, projectArgs)
699
+ .then((sections) => sections.join('\n\n'));
701
700
  if (federationWork) {
702
701
  if (!signal) return federationWork;
703
702
  let onAbort = null;
@@ -2,7 +2,7 @@
2
2
  // verbatim from patch.mjs; native protocol, cache/snapshot side effects, and
3
3
  // output formatting are unchanged.
4
4
 
5
- import { readFileSync, lstatSync, mkdirSync } from 'node:fs';
5
+ import { readFileSync, lstatSync, mkdirSync, writeFileSync } from 'node:fs';
6
6
  import { unlink } from 'node:fs/promises';
7
7
  import { dirname as pathDirname } from 'node:path';
8
8
  import { performance } from 'node:perf_hooks';
@@ -23,7 +23,6 @@ import {
23
23
  isResolvedPathOutsideBase,
24
24
  countHunkChanges,
25
25
  } from './paths.mjs';
26
- import { applyV4AHunksToLines } from './v4a-convert.mjs';
27
26
  import {
28
27
  getNativePatchServer,
29
28
  scheduleNativePatchIdleClose,
@@ -35,10 +34,14 @@ import {
35
34
  extractNativeFailurePath,
36
35
  nativeFailureMatchesEntry,
37
36
  findFirstFailingUnifiedHunk,
37
+ findUnifiedHunkMatch,
38
+ collectUnifiedNewLines,
39
+ assertSafeReplacementPlan,
38
40
  firstFailingUnifiedHunkLineDetail,
39
41
  firstMeaningfulUnifiedHunkLine,
40
42
  firstMeaningfulUnifiedEntryLine,
41
43
  compactPatchPreviewLine,
44
+ formatPatchSourceExcerpt,
42
45
  nearestPatchLineHint,
43
46
  normalizeTypographic,
44
47
  splitBufferLinesForPatch,
@@ -187,8 +190,110 @@ function entryPathKey(fullPath) {
187
190
  }
188
191
 
189
192
  function joinTextLinesForPatch(lines) {
190
- const body = (lines || []).join('\n');
191
- return lines?.hasFinalNewline !== false ? `${body}\n` : body;
193
+ const eol = lines?.eol || '\n';
194
+ const body = (lines || []).join(eol);
195
+ return lines?.hasFinalNewline !== false ? `${body}${eol}` : body;
196
+ }
197
+
198
+ // The file's dominant line terminator. splitTextLinesForPatch() folds CRLF into
199
+ // LF, so without this an out-of-base CRLF file would be rewritten as LF.
200
+ function detectDominantEol(text) {
201
+ const body = String(text ?? '');
202
+ let lf = 0;
203
+ let crlf = 0;
204
+ for (let i = 0; i < body.length; i++) {
205
+ if (body[i] !== '\n') continue;
206
+ lf++;
207
+ if (i > 0 && body[i - 1] === '\r') crlf++;
208
+ }
209
+ return lf > 0 && crlf * 2 >= lf ? '\r\n' : '\n';
210
+ }
211
+
212
+ function cloneTextLinesForPatch(sourceLines, eol) {
213
+ const lines = [...(sourceLines || [])];
214
+ lines.hasFinalNewline = sourceLines?.hasFinalNewline !== false;
215
+ lines.eol = eol || sourceLines?.eol || '\n';
216
+ return lines;
217
+ }
218
+
219
+ // Which side of the hunk, if any, carries an explicit
220
+ // `` marker. Both transitions must be honoured:
221
+ // a marker on the NEW side drops the terminator, a marker on the OLD side
222
+ // only (patch re-adds it) restores one.
223
+ function unifiedHunkEofIntent(hunk) {
224
+ let lastSide = null;
225
+ let oldNoNewline = false;
226
+ let newNoNewline = false;
227
+ for (const raw of hunk?.lines || []) {
228
+ if (typeof raw !== 'string' || raw.length === 0) continue;
229
+ const tag = raw[0];
230
+ if (tag === '\\') {
231
+ if (lastSide === 'context') { oldNoNewline = true; newNoNewline = true; }
232
+ else if (lastSide === 'old') oldNoNewline = true;
233
+ else if (lastSide === 'new') newNoNewline = true;
234
+ continue;
235
+ }
236
+ if (tag === ' ') lastSide = 'context';
237
+ else if (tag === '-') lastSide = 'old';
238
+ else if (tag === '+') lastSide = 'new';
239
+ else lastSide = null;
240
+ }
241
+ return { oldNoNewline, newNoNewline };
242
+ }
243
+
244
+ // Same recovery shape the native failure path emits: expected line, nearest
245
+ // candidate, and a bounded verbatim excerpt of the current file.
246
+ function unifiedHunkMissMessage(displayPath, hunk, sourceLines) {
247
+ const detail = firstFailingUnifiedHunkLineDetail(sourceLines, hunk)
248
+ || firstMeaningfulUnifiedHunkLine(hunk);
249
+ const expected = detail?.line || '';
250
+ const preferred = Number.isFinite(detail?.preferredLine) ? detail.preferredLine : 0;
251
+ const nearest = expected ? nearestPatchLineHint(sourceLines, expected, preferred) : '';
252
+ const excerpt = formatPatchSourceExcerpt(sourceLines, preferred, (hunk?.lines || []).length);
253
+ return `apply_patch: hunk context not found in ${displayPath}`
254
+ + (expected ? `; expected first old/context line: ${JSON.stringify(compactPatchPreviewLine(expected))}` : '')
255
+ + (nearest ? `; ${nearest}` : '')
256
+ + '; use exact current lines, no stubs.'
257
+ + excerpt;
258
+ }
259
+
260
+ // Apply already-parsed UNIFIED hunks with their declared coordinates: each
261
+ // hunk is first tried at its own `@@` position and only then fuzz-matched
262
+ // forward, mirroring the native engine. Insert-only hunks (`-N,0`) land at
263
+ // their declared index (end-of-file for a converted V4A addition) and never
264
+ // advance the cursor, so a later hunk can still resolve before them. The plan
265
+ // is validated for overlaps/equal starts BEFORE any mutation, then applied in
266
+ // descending order so earlier positions stay valid.
267
+ function applyUnifiedHunksToLines(sourceLines, hunks, { fuzz, displayPath, eol }) {
268
+ const lines = cloneTextLinesForPatch(sourceLines, eol);
269
+ const replacements = [];
270
+ let minStartIdx = 0;
271
+ for (const hunk of hunks || []) {
272
+ const match = findUnifiedHunkMatch(lines, hunk, minStartIdx, fuzz);
273
+ if (!match) throw new Error(unifiedHunkMissMessage(displayPath, hunk, lines));
274
+ const { newLines } = collectUnifiedNewLines(hunk);
275
+ const intent = unifiedHunkEofIntent(hunk);
276
+ replacements.push({
277
+ start: match.start,
278
+ oldLen: match.end - match.start,
279
+ newLines,
280
+ intent,
281
+ atEof: match.end >= lines.length,
282
+ });
283
+ minStartIdx = Math.max(minStartIdx, match.end);
284
+ }
285
+ replacements.sort((a, b) => a.start - b.start);
286
+ assertSafeReplacementPlan(replacements, `apply_patch: ${displayPath}`);
287
+ for (let i = replacements.length - 1; i >= 0; i--) {
288
+ const rep = replacements[i];
289
+ lines.splice(rep.start, rep.oldLen, ...rep.newLines);
290
+ // Explicit markers win in BOTH directions; a hunk that is silent about the
291
+ // terminator leaves the file's existing state untouched (native parity).
292
+ if (!rep.atEof) continue;
293
+ if (rep.intent.newNoNewline) lines.hasFinalNewline = false;
294
+ else if (rep.intent.oldNoNewline) lines.hasFinalNewline = true;
295
+ }
296
+ return lines;
192
297
  }
193
298
 
194
299
  function lstatRegularPatchFile(fullPath, displayPath) {
@@ -201,6 +306,18 @@ function lstatRegularPatchFile(fullPath, displayPath) {
201
306
  return st;
202
307
  }
203
308
 
309
+ // Existence probe for an Add File target: every existing path shape is
310
+ // refused, matching the native in-base guard (`create target already exists`).
311
+ function assertCreateTargetAbsent(fullPath, displayPath) {
312
+ try {
313
+ lstatSync(fullPath);
314
+ } catch (err) {
315
+ if (err?.code === 'ENOENT') return;
316
+ throw new Error(`apply_patch: create target unreadable: ${normalizeOutputPath(displayPath)} (${err?.code || err?.message || String(err)})`);
317
+ }
318
+ throw new Error(`apply_patch: create target already exists: ${normalizeOutputPath(displayPath)}`);
319
+ }
320
+
204
321
  function findParsedForRow(row, parsed, basePath) {
205
322
  const key = entryPathKey(row.fullPath);
206
323
  for (const entry of parsed || []) {
@@ -219,6 +336,9 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
219
336
  }
220
337
 
221
338
  if (kind === 'create') {
339
+ // Refuse before any write, in dry runs too, so an Add File section can
340
+ // never clobber (or follow) an existing out-of-base path.
341
+ assertCreateTargetAbsent(fullPath, displayPath);
222
342
  const addedLines = [];
223
343
  for (const hunk of entry.hunks || []) {
224
344
  for (const line of hunk.lines || []) {
@@ -228,7 +348,7 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
228
348
  const content = joinTextLinesForPatch(addedLines);
229
349
  if (!dryRun) {
230
350
  mkdirSync(pathDirname(fullPath), { recursive: true });
231
- await atomicWrite(fullPath, content, { sessionId: readStateScope });
351
+ writeFileSync(fullPath, content);
232
352
  invalidateBuiltinResultCache([fullPath]);
233
353
  markCodeGraphDirtyPaths([fullPath]);
234
354
  recordReadSnapshotForPath(fullPath, readStateScope, { source: 'apply_patch_js', isPartialView: false });
@@ -251,9 +371,13 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
251
371
 
252
372
  lstatRegularPatchFile(fullPath, displayPath);
253
373
  const raw = readFileSync(fullPath);
254
- const sourceLines = splitTextLinesForPatch(raw.toString('utf8'));
255
- const v4aHunks = (entry.hunks || []).map((h) => ({ lines: h.lines || [] }));
256
- const updatedLines = applyV4AHunksToLines(sourceLines, v4aHunks, { fuzzy });
374
+ const rawText = raw.toString('utf8');
375
+ const sourceLines = splitTextLinesForPatch(rawText);
376
+ const updatedLines = applyUnifiedHunksToLines(sourceLines, entry.hunks || [], {
377
+ fuzz: fuzzy ? 2 : 0,
378
+ displayPath,
379
+ eol: detectDominantEol(rawText),
380
+ });
257
381
  const content = joinTextLinesForPatch(updatedLines);
258
382
  if (!dryRun) {
259
383
  await atomicWrite(fullPath, content, { sessionId: readStateScope });
@@ -39,6 +39,62 @@ export function collectUnifiedOps(hunk) {
39
39
  return ops;
40
40
  }
41
41
 
42
+ // New-side lines of a unified hunk (context + additions), plus whether the
43
+ // hunk marks the NEW content as lacking a terminating newline
44
+ // (`` directly after a ' ' / '+' line).
45
+ export function collectUnifiedNewLines(hunk) {
46
+ const newLines = [];
47
+ let lastWasNew = false;
48
+ let noFinalNewline = false;
49
+ for (const raw of hunk?.lines || []) {
50
+ if (typeof raw !== 'string' || raw.length === 0) continue;
51
+ const tag = raw[0];
52
+ if (tag === ' ' || tag === '+') {
53
+ newLines.push(raw.slice(1));
54
+ lastWasNew = true;
55
+ continue;
56
+ }
57
+ if (tag === '\\') {
58
+ if (lastWasNew) noFinalNewline = true;
59
+ lastWasNew = false;
60
+ continue;
61
+ }
62
+ lastWasNew = false;
63
+ }
64
+ return { newLines, noFinalNewline };
65
+ }
66
+
67
+ // Guard a replacement plan before it is applied in DESCENDING order (the
68
+ // Codex `apply_replacements` shape). The plan must already be sorted
69
+ // ascending by `start`. Descending application is only content-safe when the
70
+ // replaced ranges are disjoint; the single well-defined exception is several
71
+ // zero-length insertions at one index, which descending application emits in
72
+ // their original source order. Everything else (an overlap, or an insertion
73
+ // sharing an index with a replacement) would silently truncate or reorder
74
+ // content, so it is rejected before any write.
75
+ export function assertSafeReplacementPlan(replacements, label = 'apply_patch') {
76
+ const plan = Array.isArray(replacements) ? replacements : [];
77
+ for (let i = 1; i < plan.length; i++) {
78
+ const prev = plan[i - 1];
79
+ const cur = plan[i];
80
+ const prevEnd = prev.start + prev.oldLen;
81
+ if (cur.start === prev.start) {
82
+ if (prev.oldLen === 0 && cur.oldLen === 0) continue;
83
+ throw new Error(
84
+ `${label}: two hunks resolve to the same position (line ${cur.start + 1}) and at least one of them replaces existing lines; `
85
+ + 'widen their context or split them into separate patches.',
86
+ );
87
+ }
88
+ if (cur.start < prevEnd) {
89
+ throw new Error(
90
+ `${label}: hunks overlap — line ${cur.start + 1} falls inside lines ${prev.start + 1}-${prevEnd} already replaced by an earlier hunk; `
91
+ + 'widen their context or split them into separate patches.',
92
+ );
93
+ }
94
+ }
95
+ return plan;
96
+ }
97
+
42
98
  export function computeUnifiedChangeBand(ops) {
43
99
  let first = null;
44
100
  let last = null;
@@ -143,7 +199,12 @@ export function unifiedOldLinesMatchAt(sourceLines, oldLines, startIdx, fuzz, ba
143
199
  const expectedBytes = toLineBytes(expected.line);
144
200
  const expectedNL = expected.hasNewline !== false;
145
201
  const actualNL = !(actualIdx === lastSrcIdx && !srcFinalNewline);
146
- const newlineOk = expectedNL === actualNL;
202
+ // Mirrors the native `newline_flags_compatible`: a hunk that ends on the
203
+ // file's LAST line encodes it with the default "has newline" unless the
204
+ // patch carries a `` marker, so that single
205
+ // EOF mismatch is tolerated for the hunk's last old line only.
206
+ const newlineOk = expectedNL === actualNL
207
+ || (offset + 1 === oldLines.length && actualIdx === lastSrcIdx && expectedNL && !actualNL);
147
208
  if (newlineOk && actualBytes.equals(expectedBytes)) continue;
148
209
  if (newlineOk && fuzz > 0 && (expected.tag === ' ' || expected.tag === '-') && byteTrimPatchWhitespace(actualBytes).equals(byteTrimPatchWhitespace(expectedBytes))) continue;
149
210
  if (
@@ -176,12 +237,15 @@ export function unifiedOldLinesMatchAt(sourceLines, oldLines, startIdx, fuzz, ba
176
237
  return { fuzzUsed, normCount };
177
238
  }
178
239
 
179
- function findUnifiedHunkMatch(sourceLines, hunk, minStartIdx, fuzz) {
240
+ export function findUnifiedHunkMatch(sourceLines, hunk, minStartIdx, fuzz) {
180
241
  const oldLines = collectUnifiedOldLines(hunk);
181
242
  const band = computeUnifiedChangeBand(collectUnifiedOps(hunk));
182
243
  const oldStart = Math.max(0, (Number(hunk?.oldStart) || 1) - 1);
183
244
  if (oldLines.length === 0) {
184
- const insertIdx = Math.max(0, Number(hunk?.oldStart) || 0);
245
+ // jsdiff bumps `oldStart` by one for a zero-old-count hunk (the unified
246
+ // "insert after line N" quirk), so the 0-based insertion index is
247
+ // `oldStart - 1`.
248
+ const insertIdx = Math.max(0, (Number(hunk?.oldStart) || 0) - 1);
185
249
  return insertIdx >= minStartIdx && insertIdx <= sourceLines.length ? { start: insertIdx, end: insertIdx } : null;
186
250
  }
187
251
  if (oldStart >= minStartIdx && unifiedOldLinesMatchAt(sourceLines, oldLines, oldStart, 0, band) !== null) {
@@ -375,6 +439,25 @@ export function findLineSequence(lines, needle, fromLine, preferredLine = 0, opt
375
439
  return starts[0];
376
440
  }
377
441
  }
442
+ // Whole-file unique-verbatim fallback: an @@ anchor that matched a LATER
443
+ // occurrence, or a previous hunk that advanced the cursor, leaves the
444
+ // forward-only search blind to a block that exists verbatim EARLIER in the
445
+ // file (measured: the majority of real context-miss failures). Accept that
446
+ // block ONLY when it matches exactly once in the entire file — zero
447
+ // ambiguity about where it belongs — and never for `*** End of File` hunks.
448
+ // assertSafeReplacementPlan still rejects any overlap with another hunk's
449
+ // replacement, so a rewound match can not silently collide.
450
+ if (fuzzy && !eof && minStart > 0) {
451
+ const starts = [];
452
+ for (let i = 0; i + needle.length <= lines.length && starts.length < 2; i++) {
453
+ let ok = true;
454
+ for (let k = 0; k < needle.length; k++) {
455
+ if (lines[i + k] !== needle[k]) { ok = false; break; }
456
+ }
457
+ if (ok) starts.push(i);
458
+ }
459
+ if (starts.length === 1) return starts[0];
460
+ }
378
461
  if (fuzzy && needle.length === 1) {
379
462
  const want = String(needle[0] ?? '').replace(/\s+/g, ' ').trim();
380
463
  if (want.length >= 40) {
@@ -17,7 +17,12 @@ import { getPluginData } from '../../config.mjs';
17
17
  import { ensurePatchBinary, findCachedPatchBinary } from '../patch-binary-fetcher.mjs';
18
18
 
19
19
  const PLUGIN_ROOT = process.env.MIXDOG_ROOT
20
- || pathResolve(pathDirname(fileURLToPath(import.meta.url)), '../../../../..');
20
+ // This module lives at src/runtime/agent/orchestrator/tools/patch/, so the
21
+ // repo root is SIX levels up. Five levels stopped at src/ and made the
22
+ // documented "local cargo build first" rule dead code: the local
23
+ // native/mixdog-patch/target/release build was never found and a stale
24
+ // cached prebuilt was used instead, so native fixes could not be exercised.
25
+ || pathResolve(pathDirname(fileURLToPath(import.meta.url)), '../../../../../..');
21
26
  const NATIVE_PATCH_DEFAULT_BIN = pathJoin(
22
27
  PLUGIN_ROOT,
23
28
  'native/mixdog-patch/target/release',