mixdog 0.9.89 → 0.9.91

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 (40) hide show
  1. package/package.json +5 -1
  2. package/src/output-styles/detailed.md +27 -0
  3. package/src/output-styles/extreme-minimal.md +8 -8
  4. package/src/output-styles/minimal.md +7 -10
  5. package/src/output-styles/simple.md +13 -21
  6. package/src/rules/lead/01-general.md +9 -1
  7. package/src/rules/lead/lead-brief.md +15 -14
  8. package/src/rules/shared/01-tool.md +31 -37
  9. package/src/runtime/agent/orchestrator/agent-runtime/commit-message-completion.mjs +67 -0
  10. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +35 -1
  11. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +28 -1
  12. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +20 -2
  13. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +116 -3
  14. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -11
  15. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -10
  16. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +15 -3
  17. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +9 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +16 -2
  19. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +176 -16
  20. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +30 -1
  22. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +96 -5
  24. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +72 -1
  25. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +48 -9
  27. package/src/runtime/channels/backends/discord.mjs +21 -1
  28. package/src/runtime/channels/tool-defs.mjs +1 -1
  29. package/src/runtime/memory/lib/trace-store.mjs +25 -3
  30. package/src/runtime/memory/tool-defs.mjs +5 -5
  31. package/src/runtime/search/tool-defs.mjs +2 -18
  32. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  33. package/src/session-runtime/output-styles.mjs +7 -6
  34. package/src/session-runtime/tool-defs.mjs +0 -1
  35. package/src/session-runtime/tool-surface.mjs +9 -0
  36. package/src/session-runtime/workflow.mjs +7 -0
  37. package/src/standalone/explore-tool.mjs +2 -2
  38. package/src/workflows/default/WORKFLOW.md +2 -1
  39. package/src/workflows/solo/WORKFLOW.md +7 -5
  40. package/src/output-styles/default.md +0 -40
@@ -1,6 +1,8 @@
1
- import { statSync } from 'fs';
1
+ import { closeSync, openSync, readSync, statSync, unlinkSync } from 'fs';
2
2
  import { assertPathReachable, assertPathsReachable } from './fs-reachability.mjs';
3
- import { isAbsolute, resolve } from 'path';
3
+ import { isAbsolute, join, resolve } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { randomUUID } from 'crypto';
4
6
  import { WRAPPER_NAMES } from '../shell-policy.mjs';
5
7
  import {
6
8
  cwdRelativePath,
@@ -761,7 +763,10 @@ export async function analyzeShellCommandEffects(command, cwd) {
761
763
  // its deadline. Only the first top-level segment is considered — sleeps inside
762
764
  // pipelines/subshells/scripts are legitimate pacing and pass through. Float
763
765
  // durations (sleep 0.5) are allowed, mirroring the reference CLI.
764
- function detectBlockedSleepPattern(command) {
766
+ // Exported: bash-tool promotes a detected sleep-chain to a background task
767
+ // BEFORE foregroundLongCommandHint runs, so in the one-shot shell path this
768
+ // deny only survives for callers that do not promote (task-tool preflight).
769
+ export function detectBlockedSleepPattern(command) {
765
770
  const cmd = String(command || '').trim();
766
771
  if (!cmd) return null;
767
772
  const sep = cmd.match(/&&|\|\||;|\|/);
@@ -782,22 +787,62 @@ function detectBlockedSleepPattern(command) {
782
787
  return rest ? `sleep ${secs} followed by: ${rest.slice(0, 80)}` : `standalone sleep ${secs}`;
783
788
  }
784
789
 
785
- export function foregroundLongCommandHint(command, timeoutMs, args = {}, opts = {}) {
786
- if (args.run_in_background === true) return '';
787
- const cmd = String(command || '').trim();
788
- if (!cmd) return '';
789
- // CC validateInput parity: block only while background tasks are enabled —
790
- // the remedy we point at (async mode / completion notification) must exist.
791
- if (opts.backgroundTasksDisabled !== true) {
792
- const blocked = detectBlockedSleepPattern(cmd);
793
- if (blocked) {
794
- return `Error: blocked — ${blocked}. Run blocking commands as a background task (mode:"async") and act on the completion notification, or use task wait on an existing task_id. If you genuinely need a delay (rate limiting, pacing), keep it under 2 seconds.`;
790
+ // Codex-parity shell interception (core maybe_parse_apply_patch_verified):
791
+ // codex-trained models type `apply_patch <<'EOF' … EOF` INTO THE SHELL. No
792
+ // such binary exists here, so the invocation is extracted and routed to the
793
+ // internal apply_patch engine instead of dying as "command not found".
794
+ // Returns null when the command is not an apply_patch invocation, { patch }
795
+ // when one was extracted, or { error } for a recognized-but-malformed call.
796
+ export function extractShellApplyPatchInvocation(command) {
797
+ let cmd = String(command || '').trim();
798
+ if (!cmd) return null;
799
+ // Unwrap one `bash|sh|zsh -lc '<script>'` / `-c "<script>"` layer.
800
+ const wrap = /^(?:bash|sh|zsh)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/.exec(cmd);
801
+ if (wrap) cmd = wrap[2].trim();
802
+ // A bare patch pasted into the shell (codex ImplicitInvocation case) is
803
+ // unambiguous — route it to the engine directly.
804
+ if (cmd.startsWith('*** Begin Patch')) return { patch: cmd };
805
+ if (!/^apply_patch(?:\s|$)/.test(cmd)) return null;
806
+ const rest = cmd.slice('apply_patch'.length).trim();
807
+ // Heredoc form: apply_patch <<'EOF' \n <patch> \n EOF
808
+ const heredoc = /^<<-?\s*(['"]?)(\w+)\1\s*\n([\s\S]*)$/.exec(rest);
809
+ if (heredoc) {
810
+ const terminator = heredoc[2];
811
+ const lines = heredoc[3].split('\n');
812
+ const endIdx = lines.findIndex((l) => l.trim() === terminator);
813
+ const patch = (endIdx === -1 ? lines : lines.slice(0, endIdx)).join('\n').trim();
814
+ if (patch) return { patch };
815
+ return { error: 'apply_patch heredoc contained no patch body' };
816
+ }
817
+ // Single-argument form: apply_patch '<patch>' / "<patch>" / bare.
818
+ if (rest) {
819
+ const q = rest[0];
820
+ let patch = rest;
821
+ if ((q === "'" || q === '"') && rest.length >= 2 && rest.endsWith(q)) {
822
+ patch = rest.slice(1, -1);
823
+ if (q === '"') patch = patch.replace(/\\([\\"$`])/g, '$1');
824
+ else patch = patch.replace(/'\\''/g, "'");
795
825
  }
826
+ patch = patch.trim();
827
+ if (patch.startsWith('*** Begin Patch')) return { patch };
828
+ return { error: 'apply_patch argument did not contain a V4A patch (expected "*** Begin Patch")' };
796
829
  }
797
- const longTimeout = Number(timeoutMs) >= 120_000;
830
+ return { error: 'apply_patch requires the patch text (heredoc or single argument)' };
831
+ }
832
+
833
+ // Watch-like / long-blocking-sleep detection shared by bash-tool's auto-async
834
+ // promotion and foregroundLongCommandHint. Returns a short human reason when
835
+ // the command would block the foreground for a long time (dev server, watcher,
836
+ // or a 30s+ sleep anywhere in the chain), else null. bash-tool promotes these
837
+ // to a background task instead of failing the call — measured 2026-08: the
838
+ // old hard deny fired ~22×/14d and every hit was a wasted turn.
839
+ export function detectLongForegroundReason(command) {
840
+ const cmd = String(command || '').trim();
841
+ if (!cmd) return null;
798
842
  const watchLike = /^\s*gh\s+run\s+watch(?:\s|$)/i.test(cmd)
799
843
  || /^\s*(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|watch|serve)(?:\s|$)/i.test(cmd)
800
844
  || /^\s*(?:vite|webpack-dev-server|next|nuxt|astro)\s+(?:dev|start)(?:\s|$)/i.test(cmd);
845
+ if (watchLike) return 'watch-like long-running command (dev server / watcher)';
801
846
  const longPowerShellSleep = [...cmd.matchAll(/\bStart-Sleep\b([^;&|\r\n]*)/gi)].some((m) => {
802
847
  const part = String(m[1] || '');
803
848
  const ms = part.match(/-(?:Milliseconds|m)\s+(\d+)/i);
@@ -806,8 +851,23 @@ export function foregroundLongCommandHint(command, timeoutMs, args = {}, opts =
806
851
  return sec ? Number(sec[1]) >= 30 : false;
807
852
  });
808
853
  const longSleep = /\bsleep\s+(?:[3-9]\d|\d{3,}|\d+[mh])\b/i.test(cmd) || longPowerShellSleep;
809
- if (!watchLike && !longSleep) return '';
810
- if (!longTimeout && !watchLike && !longSleep) return '';
854
+ if (longSleep) return 'long blocking sleep (30s+) in command';
855
+ return null;
856
+ }
857
+
858
+ export function foregroundLongCommandHint(command, timeoutMs, args = {}, opts = {}) {
859
+ if (args.run_in_background === true) return '';
860
+ const cmd = String(command || '').trim();
861
+ if (!cmd) return '';
862
+ // CC validateInput parity: block only while background tasks are enabled —
863
+ // the remedy we point at (async mode / completion notification) must exist.
864
+ if (opts.backgroundTasksDisabled !== true) {
865
+ const blocked = detectBlockedSleepPattern(cmd);
866
+ if (blocked) {
867
+ return `Error: blocked — ${blocked}. Run blocking commands as a background task (mode:"async") and act on the completion notification, or use task wait on an existing task_id. If you genuinely need a delay (rate limiting, pacing), keep it under 2 seconds.`;
868
+ }
869
+ }
870
+ if (!detectLongForegroundReason(cmd)) return '';
811
871
  return 'Error: long foreground command detected. Use mode:"async" (background task) or task wait instead of blocking sleeps/watch loops.';
812
872
  }
813
873
 
@@ -870,3 +930,103 @@ export function isAutobackgroundingAllowed(command, shellType) {
870
930
  }
871
931
  return true;
872
932
  }
933
+
934
+ // ---------------------------------------------------------------------------
935
+ // Filter-swallow rescue (PowerShell one-shot path). Measured 2026-08: ~37
936
+ // failures/14d were `<producer> 2>&1 | Select-String … | Select-Object …`
937
+ // pipelines where the producer failed but the trailing filters matched
938
+ // nothing — the model saw `[exit code: 1]` + `(no output)` and burned turns
939
+ // re-running with different capture tricks. Plan: insert a Tee-Object spill
940
+ // of the UNFILTERED producer stream so a failing run can attach the original
941
+ // tail in the SAME call. Applies only to the exactly-recognized shape; any
942
+ // ambiguity returns null and the original command runs untouched:
943
+ // - single-line command, no backticks / $( ) / ${ } / here-strings / procsub
944
+ // - no existing Tee-Object
945
+ // - last top-level segment is a byte-exact suffix of the command
946
+ // - >= 2 pipeline stages; stage 0 is NOT a pure filter; stages 1..n are ALL
947
+ // known pure pass-through filters; no stage redirects to a file
948
+ // Tee-Object passes objects through unchanged and does not touch
949
+ // $LASTEXITCODE, so success-path output and exit semantics are identical.
950
+ const _PS_PURE_FILTER_HEADS = new Set([
951
+ 'select-string', 'sls', 'select-object', 'select',
952
+ 'where-object', 'where', 'foreach-object', '%', 'findstr',
953
+ ]);
954
+ const _TEE_AMBIGUOUS_RE = /[`]|\$\(|\$\{|@['"]|<\(|>\(/;
955
+
956
+ function _teeStageHead(tokens) {
957
+ return String(tokens?.[0] || '').toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
958
+ }
959
+
960
+ function _stageRedirectsToFile(tokens) {
961
+ for (let i = 0; i < tokens.length; i++) {
962
+ if (tokens[i] === '>' || tokens[i] === '>>') {
963
+ const next = String(tokens[i + 1] || '');
964
+ if (!next.startsWith('&')) return true; // `> file` (2>&1-style stays allowed)
965
+ }
966
+ }
967
+ return false;
968
+ }
969
+
970
+ export function buildPowerShellFilterTeePlan(command) {
971
+ const cmd = String(command || '');
972
+ if (!cmd.trim()) return null;
973
+ if (/\r|\n/.test(cmd)) return null;
974
+ if (/tee-object/i.test(cmd)) return null;
975
+ if (_TEE_AMBIGUOUS_RE.test(cmd)) return null;
976
+ const segments = shellSplitSegments(cmd);
977
+ if (segments.length === 0) return null;
978
+ const last = segments[segments.length - 1];
979
+ const trimmed = cmd.trimEnd();
980
+ if (!trimmed.endsWith(last)) return null;
981
+ const stages = shellSplitPipelineSegments(last);
982
+ if (stages.length < 2) return null;
983
+ const tokenized = [];
984
+ for (const stage of stages) {
985
+ const tokens = shellTokenize(stage);
986
+ if (!tokens || tokens.length === 0) return null;
987
+ if (_stageRedirectsToFile(tokens)) return null;
988
+ const stripped = stripShellProbeWrappers(tokens);
989
+ if (stripped.length === 0) return null;
990
+ tokenized.push(stripped);
991
+ }
992
+ if (_PS_PURE_FILTER_HEADS.has(_teeStageHead(tokenized[0]))) return null;
993
+ for (let i = 1; i < tokenized.length; i++) {
994
+ if (!_PS_PURE_FILTER_HEADS.has(_teeStageHead(tokenized[i]))) return null;
995
+ }
996
+ const teePath = join(tmpdir(), `mixdog-tee-${randomUUID().slice(0, 8)}.log`);
997
+ if (teePath.includes("'")) return null;
998
+ const head = trimmed.slice(0, trimmed.length - last.length);
999
+ const rewritten = `${head}${stages[0]} | Tee-Object -FilePath '${teePath}' | ${stages.slice(1).join(' | ')}`;
1000
+ return { command: rewritten, teePath };
1001
+ }
1002
+
1003
+ // Read the tail of a tee spill file (BOM-sniffed: Windows PowerShell 5.1
1004
+ // writes UTF-16LE, pwsh 7 writes UTF-8) and ALWAYS delete the file. Returns
1005
+ // null when the file is missing/empty/unreadable.
1006
+ export function consumeFilterTeeCapture(teePath, { maxBytes = 16384 } = {}) {
1007
+ let out = null;
1008
+ try {
1009
+ const st = statSync(teePath);
1010
+ if (st.size > 0) {
1011
+ const fd = openSync(teePath, 'r');
1012
+ try {
1013
+ const bom = Buffer.alloc(2);
1014
+ readSync(fd, bom, 0, 2, 0);
1015
+ const utf16 = bom[0] === 0xff && bom[1] === 0xfe;
1016
+ let start = Math.max(0, st.size - maxBytes);
1017
+ if (utf16 && start % 2 === 1) start += 1;
1018
+ const len = st.size - start;
1019
+ const buf = Buffer.alloc(len);
1020
+ readSync(fd, buf, 0, len, start);
1021
+ out = buf.toString(utf16 ? 'utf16le' : 'utf8');
1022
+ if (start === 0 && out.charCodeAt(0) === 0xfeff) out = out.slice(1);
1023
+ } finally {
1024
+ closeSync(fd);
1025
+ }
1026
+ }
1027
+ } catch {
1028
+ out = null;
1029
+ }
1030
+ try { unlinkSync(teePath); } catch { /* best-effort cleanup */ }
1031
+ return out && out.trim() ? out : null;
1032
+ }
@@ -3,7 +3,7 @@ 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 verified source files only. IDs→graph; literal/zero→grep. File modes take files[]; symbol modes find_symbol/symbol_search/search/references/callers/callees take symbols[]. Exact identifiers use find_symbol/references/callers/callees and keywords use symbol_search/search. Unsupported target arrays are omitted, never silently mixed. Batch targets per mode.',
6
+ description: 'Repo code structure/flow over source files only. File modes take files[]; symbol modes find_symbol/symbol_search/search/references/callers/callees take symbols[]. Exact identifiers use find_symbol/references/callers/callees and keywords use symbol_search/search. Unsupported target arrays are omitted, never silently mixed.',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {
@@ -48,6 +48,12 @@ const EXPECTED_ARGS = Object.freeze(['-NoLogo', '-NoProfile', '-NonInteractive',
48
48
  // with the same `$?`→0/1 exit mapping pwsh -Command applies implicitly, then
49
49
  // emits `<<<MIXDOG_DONE_<nonce>:<code>>>>` on stdout and
50
50
  // `<<<MIXDOG_DONE_<nonce>>>>` on stderr (both streams provably drained).
51
+ // Each command pipes through Microsoft.PowerShell.Core\Out-Default: the whole
52
+ // marker loop is ONE top-level pipeline, so without an explicit per-command
53
+ // formatting terminator Format-Table kept object output buffered PAST the done
54
+ // sentinel and captures came back empty (`Get-ChildItem | Select-Object` →
55
+ // no output). The per-command Out-Default flushes formatted output before the
56
+ // sentinel is written, restoring one-shot `pwsh -Command` parity.
51
57
  // stdin EOF exits the process with the LAST command's code — that is exactly
52
58
  // what background-promotion relies on for its exit-file. A user script that
53
59
  // calls `exit N` terminates the whole process with N (identical code to the
@@ -63,6 +69,20 @@ function _standbyBootScript(nonce) {
63
69
  '$__baseEnv = @{}',
64
70
  'Get-ChildItem env: | ForEach-Object { $__baseEnv[$_.Name] = $_.Value }',
65
71
  '$__baseLoc = (Get-Location).ProviderPath',
72
+ // Session-isolation baselines: env+cwd alone leaked GLOBAL functions,
73
+ // aliases, variables, modules, and preference values across commands
74
+ // (the pool is daemon-global, so across SESSIONS too). Only global
75
+ // scope can leak — a scriptblock's local defs die with `& $__sc`.
76
+ '$__baseFn = @{}',
77
+ 'Get-ChildItem function: | ForEach-Object { $__baseFn[$_.Name] = $true }',
78
+ '$__baseAlias = @{}',
79
+ 'Get-ChildItem alias: | ForEach-Object { $__baseAlias[$_.Name] = $_.Definition }',
80
+ '$__baseVar = @{}',
81
+ 'Get-Variable -Scope Global -ErrorAction SilentlyContinue | ForEach-Object { $__baseVar[$_.Name] = $true }',
82
+ '$__baseMod = @{}',
83
+ 'Get-Module | ForEach-Object { $__baseMod[$_.Name] = $true }',
84
+ '$__basePref = @{}',
85
+ "foreach ($__p in @('ErrorActionPreference','ProgressPreference','VerbosePreference','WarningPreference','InformationPreference','DebugPreference','ConfirmPreference')) { $__basePref[$__p] = Get-Variable -Name $__p -ValueOnly -Scope Global -ErrorAction SilentlyContinue }",
66
86
  '$__code = 0',
67
87
  'while ($true) {',
68
88
  ' $__buf = New-Object System.Text.StringBuilder',
@@ -74,12 +94,21 @@ function _standbyBootScript(nonce) {
74
94
  ' }',
75
95
  ' foreach ($__n in @(Get-ChildItem env: | ForEach-Object Name)) { if (-not $__baseEnv.ContainsKey($__n)) { Remove-Item ("env:$__n") -ErrorAction SilentlyContinue } }',
76
96
  ' foreach ($__k in $__baseEnv.Keys) { Set-Item ("env:$__k") $__baseEnv[$__k] }',
97
+ // Strip global state the previous command left behind; restore
98
+ // overwritten alias definitions and preference values. `__*` variable
99
+ // names are the loop's own internals (created after baseline) and are
100
+ // never removed.
101
+ ' foreach ($__n in @(Get-ChildItem function: | ForEach-Object Name)) { if (-not $__baseFn.ContainsKey($__n)) { Remove-Item ("function:$__n") -Force -ErrorAction SilentlyContinue } }',
102
+ ' foreach ($__a in @(Get-ChildItem alias:)) { if (-not $__baseAlias.ContainsKey($__a.Name)) { Remove-Item ("alias:$($__a.Name)") -Force -ErrorAction SilentlyContinue } elseif ($__a.Definition -ne $__baseAlias[$__a.Name]) { Set-Alias -Name $__a.Name -Value $__baseAlias[$__a.Name] -Scope Global -Force -ErrorAction SilentlyContinue } }',
103
+ ' foreach ($__n in @(Get-Variable -Scope Global -ErrorAction SilentlyContinue | ForEach-Object Name)) { if ($__n -notlike "__*" -and -not $__baseVar.ContainsKey($__n)) { Remove-Variable -Name $__n -Scope Global -Force -ErrorAction SilentlyContinue } }',
104
+ ' foreach ($__n in @(Get-Module | ForEach-Object Name)) { if (-not $__baseMod.ContainsKey($__n)) { Remove-Module -Name $__n -Force -ErrorAction SilentlyContinue } }',
105
+ ' foreach ($__n in @($__basePref.Keys)) { Set-Variable -Name $__n -Value $__basePref[$__n] -Scope Global -Force -ErrorAction SilentlyContinue }',
77
106
  ' Set-Location -LiteralPath $__baseLoc',
78
107
  ' $global:LASTEXITCODE = 0',
79
108
  ' $global:__MIXDOG_RC = 1',
80
109
  ' try {',
81
110
  " $__sc = [ScriptBlock]::Create($__buf.ToString() + [Environment]::NewLine + 'if ($?) { $global:__MIXDOG_RC = 0 } else { $global:__MIXDOG_RC = 1 }')",
82
- ' & $__sc',
111
+ ' & $__sc | Microsoft.PowerShell.Core\\Out-Default',
83
112
  ' } catch {',
84
113
  ' $global:__MIXDOG_RC = 1',
85
114
  " [Console]::Error.WriteLine('Exception: ' + $_.Exception.Message)",
@@ -383,7 +383,7 @@ export function splitBufferLinesForPatch(buf) {
383
383
  return lines;
384
384
  }
385
385
 
386
- function longestCommonSubstringLen(a, b, cap = 4000) {
386
+ export function longestCommonSubstringLen(a, b, cap = 4000) {
387
387
  if (!a || !b) return 0;
388
388
  const A = a.length > cap ? a.slice(0, cap) : a;
389
389
  const B = b.length > cap ? b.slice(0, cap) : b;
@@ -14,7 +14,6 @@ import {
14
14
  withBuiltinPathLocks,
15
15
  } from '../builtin.mjs';
16
16
  import { withAdvisoryLocks } from '../builtin/advisory-lock.mjs';
17
- import { wrapMutationRouteOutput } from '../mutation-planner.mjs';
18
17
  import { getPluginData } from '../../config.mjs';
19
18
  import { markCodeGraphDirtyPaths } from '../code-graph-state.mjs';
20
19
  import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, parseUnifiedBareV4APatch, parseUnifiedCountedAsV4APatch, isCompactedPlaceholderPatch, salvageV4AOpening } from './parsing.mjs';
@@ -509,9 +508,100 @@ function planApplyPatchMutationRoute(args, patchStr, requestedFormat) {
509
508
  };
510
509
  }
511
510
 
512
- function wrapPatchMutationOutput(text, plan, extras = {}) {
513
- if (isPatchErrorText(text)) return text;
514
- return wrapMutationRouteOutput(text, plan, extras);
511
+ // Model-surface success outputs deliberately DROP the `mutation_route:`
512
+ // diagnostic header (codex parity: `Success. Updated the following files:`).
513
+ // Errors were never wrapped, the UI diff side-channel is independent, and no
514
+ // script/UI parses the header — measured saving ~30 tokens x ~125 successful
515
+ // patch calls/day. Route diagnostics stay available via plan/extras callers.
516
+ function wrapPatchMutationOutput(text, _plan, _extras = {}) {
517
+ return text;
518
+ }
519
+
520
+ // Post-patch excerpt: a successful patch result carries the changed span's
521
+ // CURRENT on-disk lines (numbered, verbatim). This embeds the follow-up
522
+ // look-up INSIDE the mutation turn — the next patch to the same region has
523
+ // byte-exact context in-session without spending a read/grep turn, which
524
+ // removes the fuel for stale-own-edit context misses (the measured top
525
+ // remaining failure class).
526
+ //
527
+ // REPEAT-GATED: batched one-shot patches (the common case under the
528
+ // all-edits-in-one-patch rule) rarely get a follow-up, so the FIRST patch of
529
+ // a file in a session appends nothing — zero cost. Only a repeat patch of
530
+ // the same file (the iterative fix-loop signal, exactly where stale-own-edit
531
+ // misses happen) pays for the excerpt.
532
+ const _patchedFilesByScope = new Map(); // scopeKey -> Map(fileKey -> ts)
533
+ const PATCHED_FILES_SCOPE_CAP = 64;
534
+ const PATCHED_FILES_PER_SCOPE_CAP = 300;
535
+ function _scopePatchedFiles(scopeKey) {
536
+ let seen = _patchedFilesByScope.get(scopeKey);
537
+ if (!seen) {
538
+ seen = new Map();
539
+ _patchedFilesByScope.set(scopeKey, seen);
540
+ while (_patchedFilesByScope.size > PATCHED_FILES_SCOPE_CAP) {
541
+ _patchedFilesByScope.delete(_patchedFilesByScope.keys().next().value);
542
+ }
543
+ }
544
+ return seen;
545
+ }
546
+ const POST_PATCH_EXCERPT_MAX_FILES = 3;
547
+ const POST_PATCH_EXCERPT_MAX_LINES = 10;
548
+ const POST_PATCH_EXCERPT_MAX_CHARS = 1400;
549
+ export function appendPostPatchExcerpts(outputText, patchStr, requestedFormat, basePath, readStateScope) {
550
+ try {
551
+ if (isPatchErrorText(outputText)) return outputText;
552
+ if (!isV4APatchInput(patchStr, requestedFormat)) return outputText;
553
+ const sections = parseV4APatch(patchStr).filter((s) => s.kind === 'update' && s.hunks?.length);
554
+ if (!sections.length) return outputText;
555
+ const seen = _scopePatchedFiles(String(readStateScope || 'global'));
556
+ const repeats = [];
557
+ for (const section of sections) {
558
+ const target = section.movePath || section.path;
559
+ let fileKey;
560
+ try { fileKey = resolveV4AEntryPath(basePath, target); } catch { continue; }
561
+ if (process.platform === 'win32') fileKey = fileKey.toLowerCase();
562
+ if (seen.has(fileKey)) repeats.push(section);
563
+ seen.set(fileKey, Date.now());
564
+ while (seen.size > PATCHED_FILES_PER_SCOPE_CAP) seen.delete(seen.keys().next().value);
565
+ }
566
+ if (!repeats.length) return outputText;
567
+ const chunks = [];
568
+ let chars = 0;
569
+ for (const section of repeats.slice(0, POST_PATCH_EXCERPT_MAX_FILES)) {
570
+ const target = section.movePath || section.path;
571
+ let fileLines;
572
+ try {
573
+ fileLines = readFileSync(resolveV4AEntryPath(basePath, target), 'utf8').replace(/\r\n/g, '\n').split('\n');
574
+ } catch { continue; }
575
+ const hunk = section.hunks[0];
576
+ const newSide = (hunk.lines || [])
577
+ .filter((l) => l && (l[0] === ' ' || l[0] === '+'))
578
+ .map((l) => l.slice(1));
579
+ if (!newSide.length) continue;
580
+ let at = -1;
581
+ outer: for (let i = 0; i + newSide.length <= fileLines.length; i++) {
582
+ for (let k = 0; k < newSide.length; k++) {
583
+ if (fileLines[i + k] !== newSide[k]) continue outer;
584
+ }
585
+ at = i; break;
586
+ }
587
+ if (at < 0) continue;
588
+ const shown = Math.min(newSide.length, POST_PATCH_EXCERPT_MAX_LINES);
589
+ const rows = [];
590
+ for (let i = 0; i < shown; i++) {
591
+ rows.push(`${String(at + i + 1).padStart(5, ' ')}| ${fileLines[at + i]}`);
592
+ }
593
+ if (newSide.length > shown) rows.push(' | …');
594
+ const more = section.hunks.length > 1 ? ` (+${section.hunks.length - 1} more hunk${section.hunks.length > 2 ? 's' : ''})` : '';
595
+ const block = `${String(target).replace(/\\/g, '/')} lines ${at + 1}-${at + shown}${more}:\n${rows.join('\n')}`;
596
+ chars += block.length;
597
+ if (chars > POST_PATCH_EXCERPT_MAX_CHARS) break;
598
+ chunks.push(block);
599
+ }
600
+ if (!chunks.length) return outputText;
601
+ return `${outputText}\npost-patch state (verbatim — use for follow-up patches):\n${chunks.join('\n')}`;
602
+ } catch {
603
+ return outputText;
604
+ }
515
605
  }
516
606
 
517
607
  const APPLY_PATCH_SCHEMA_KEYS = new Set(['patch', 'format', 'base_path', 'dry_run', 'reject_partial', 'fuzzy', 'sequence', 'mode']);
@@ -583,11 +673,12 @@ async function apply_patch(args, cwd, options = {}) {
583
673
  const legacyBulkMode = args?.sequence === false
584
674
  || ['atomic', 'bulk'].includes(patchMode);
585
675
  if (!legacyBulkMode) {
586
- return applyPatchSequence(patchStr, requestedFormat, basePath, {
676
+ const seqOut = await applyPatchSequence(patchStr, requestedFormat, basePath, {
587
677
  v4aConvertOpts, dryRun, fuzz, fuzzy, rejectPartial,
588
678
  readStateScope, abortSignal, mutationPlan,
589
679
  rollbackOnFailure: patchMode !== 'partial',
590
680
  });
681
+ return dryRun ? seqOut : appendPostPatchExcerpts(seqOut, patchStr, requestedFormat, basePath, readStateScope);
591
682
  }
592
683
  let v4aRenamePlan = null;
593
684
  if (isV4APatchInput(patchStr, requestedFormat)) {
@@ -27,6 +27,7 @@ import {
27
27
  import {
28
28
  findLineSequence,
29
29
  findLineSequenceEscapeEquiv,
30
+ longestCommonSubstringLen,
30
31
  splitTextLinesForPatch,
31
32
  firstMeaningfulPatchLine,
32
33
  nearestPatchLineHint,
@@ -42,6 +43,7 @@ function v4AHunkLineStats(hunk) {
42
43
  let newCount = 0;
43
44
  const oldLines = [];
44
45
  const newLines = [];
46
+ const oldTags = [];
45
47
  for (const raw of hunk.lines || []) {
46
48
  if (!raw) continue;
47
49
  const tag = raw[0];
@@ -51,15 +53,17 @@ function v4AHunkLineStats(hunk) {
51
53
  newCount++;
52
54
  oldLines.push(body);
53
55
  newLines.push(body);
56
+ oldTags.push(' ');
54
57
  } else if (tag === '-') {
55
58
  oldCount++;
56
59
  oldLines.push(body);
60
+ oldTags.push('-');
57
61
  } else if (tag === '+') {
58
62
  newCount++;
59
63
  newLines.push(body);
60
64
  }
61
65
  }
62
- return { oldCount, newCount, oldLines, newLines };
66
+ return { oldCount, newCount, oldLines, newLines, oldTags };
63
67
  }
64
68
 
65
69
  function findAnchorLine(lines, anchors, fromLine) {
@@ -164,6 +168,46 @@ function eofInsertionIndex(sourceLines) {
164
168
  return len > 0 && sourceLines[len - 1] === '' ? len - 1 : len;
165
169
  }
166
170
 
171
+ // Bounded context-tolerance tier (fuzzy, non-EOF, last resort before the
172
+ // context-miss error). Recovers the measured top remaining failure class —
173
+ // 1-2 nearby context lines drifted since the model last saw the file — under
174
+ // guards that make mis-application practically impossible:
175
+ // - every '-' (deletion) line must match the file byte-exactly;
176
+ // - only ' ' (context) lines may mismatch, at most 2, and each drifted
177
+ // line must still resemble the file line (shared substring >= half);
178
+ // - at least 2 exact non-blank matches must anchor the window;
179
+ // - the qualifying window must be UNIQUE across the ENTIRE file;
180
+ // - the caller REMAPS tolerated context to the file's on-disk lines, so a
181
+ // drifted patch line never overwrites file content it did not target.
182
+ function findContextTolerantWindow(sourceLines, oldLines, oldTags) {
183
+ const n = oldLines.length;
184
+ if (n < 3 || !Array.isArray(oldTags) || oldTags.length !== n) return null;
185
+ const collapse = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
186
+ const similar = (a, b) => {
187
+ const ca = collapse(a);
188
+ const cb = collapse(b);
189
+ if (!ca || !cb) return false;
190
+ return longestCommonSubstringLen(ca, cb) >= Math.max(4, Math.ceil(Math.max(ca.length, cb.length) / 2));
191
+ };
192
+ const windows = [];
193
+ for (let i = 0; i + n <= sourceLines.length && windows.length < 2; i++) {
194
+ let mismatches = 0;
195
+ let exactNonBlank = 0;
196
+ let ok = true;
197
+ for (let k = 0; k < n; k++) {
198
+ const pat = oldLines[k];
199
+ const src = sourceLines[i + k];
200
+ if (src === pat) {
201
+ if (collapse(pat)) exactNonBlank++;
202
+ continue;
203
+ }
204
+ if (oldTags[k] !== ' ' || ++mismatches > 2 || !similar(pat, src)) { ok = false; break; }
205
+ }
206
+ if (ok && mismatches > 0 && exactNonBlank >= 2) windows.push(i);
207
+ }
208
+ return windows.length === 1 ? { start: windows[0] } : null;
209
+ }
210
+
167
211
  function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {}) {
168
212
  const stats = v4AHunkLineStats(hunk);
169
213
  if (stats.oldCount === 0 && stats.newCount === 0) return { skip: true };
@@ -242,6 +286,33 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
242
286
  }
243
287
  }
244
288
  }
289
+ // Context-tolerance tier: see findContextTolerantWindow. Tolerated context
290
+ // lines are remapped to the file's actual lines on BOTH the old and new
291
+ // side (same shape as the escape-equiv remap above); remapping bails on any
292
+ // string ambiguity so an unrelated occurrence can never be rewritten.
293
+ if (oldStartIdx < 0 && fuzzy && !eof && oldLinesPattern.length >= 3) {
294
+ const tolTags = trimmedTrailing ? stats.oldTags.slice(0, -1) : stats.oldTags;
295
+ const tol = findContextTolerantWindow(sourceLines, oldLinesPattern, tolTags);
296
+ if (tol) {
297
+ const remapped = new Map();
298
+ let ambiguous = false;
299
+ for (let k = 0; k < oldLinesPattern.length; k++) {
300
+ const pat = oldLinesPattern[k];
301
+ const src = sourceLines[tol.start + k];
302
+ if (src === pat) continue;
303
+ if (
304
+ oldLinesPattern.filter((l) => l === pat).length > 1
305
+ || (remapped.has(pat) && remapped.get(pat) !== src)
306
+ ) { ambiguous = true; break; }
307
+ remapped.set(pat, src);
308
+ }
309
+ if (!ambiguous) {
310
+ newLinesPattern = newLinesPattern.map((l) => remapped.get(l) ?? l);
311
+ oldLinesPattern = oldLinesPattern.map((_, k) => sourceLines[tol.start + k]);
312
+ oldStartIdx = tol.start;
313
+ }
314
+ }
315
+ }
245
316
  if (oldStartIdx < 0) {
246
317
  const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}`;
247
318
  return { error: msg };
@@ -48,7 +48,7 @@ const APPLY_PATCH_JSON_DESCRIPTION = [
48
48
  '- *** Delete File: <path> with nothing after it.',
49
49
  '- *** Update File: <path>, optionally followed by *** Move to: <new path>.',
50
50
  'Updates contain hunks introduced by @@ or @@ <enclosing class/function>. Prefix every hunk line with space (context), - (remove), or + (add); a hunk at end of file may close with *** End of File.',
51
- 'Copy 3 context lines above and below verbatim from the file. Do not duplicate overlapping context. If context is not unique, add one or more enclosing @@ headers.',
51
+ 'Copy 3 context lines above and below verbatim from the newest tool output of the file (never retype from memory; after your own patch, use post-patch content). Do not duplicate overlapping context. If context is not unique, add one or more enclosing @@ headers.',
52
52
  'Use project-relative paths. Every added file line needs +. Never submit a compacted-history marker; re-read and create a fresh patch.',
53
53
  ].join('\n');
54
54
 
@@ -115,9 +115,39 @@ function _admissionSaturationError(admission, waitMs) {
115
115
  return error;
116
116
  }
117
117
 
118
- async function _acquireShellLeaseBounded(admission, { abortSignal, label }) {
118
+ // Memory-pressure rejections from resource-admission are TRANSIENT: they clear
119
+ // when concurrent work finishes releasing RSS/host memory. Measured 2026-08:
120
+ // ~25 shell calls/14d failed instantly on `[resource pressure]` during
121
+ // parallel bench/build waves. Instead of failing the tool call, retry the
122
+ // acquire on a short backoff inside the existing admission deadline so most
123
+ // of those become slightly-delayed successes. Non-memory errors (queue full,
124
+ // detached-dependency, saturation deadline, aborts) still throw immediately.
125
+ function _isMemoryPressureError(err) {
126
+ if (!err || err.code !== 'ERESOURCEPRESSURE') return false;
127
+ if (err.metric === 'rss' || err.metric === 'free-memory') return true;
128
+ return /memory metrics unavailable/i.test(String(err.message || ''));
129
+ }
130
+
131
+ function _abortableDelay(ms, signal) {
132
+ return new Promise((resolveDelay) => {
133
+ let onAbort = null;
134
+ // NOT unref'd: this delay is awaited foreground work — an unref'd timer
135
+ // lets the event loop drain and strands the retry loop forever.
136
+ const timer = setTimeout(() => {
137
+ if (onAbort && signal) { try { signal.removeEventListener('abort', onAbort); } catch {} }
138
+ resolveDelay();
139
+ }, ms);
140
+ if (signal) {
141
+ onAbort = () => { clearTimeout(timer); resolveDelay(); };
142
+ if (signal.aborted) onAbort();
143
+ else signal.addEventListener('abort', onAbort, { once: true });
144
+ }
145
+ });
146
+ }
147
+
148
+ export async function acquireShellLeaseBounded(admission, { abortSignal, label, dependency = 'scoped' } = {}) {
119
149
  if (!(SHELL_ADMISSION_WAIT_MS > 0)) {
120
- return admission.acquire('shell', { signal: abortSignal || null, label });
150
+ return admission.acquire('shell', { signal: abortSignal || null, label, dependency });
121
151
  }
122
152
  const ctl = new AbortController();
123
153
  const onAbort = () => {
@@ -127,17 +157,26 @@ async function _acquireShellLeaseBounded(admission, { abortSignal, label }) {
127
157
  if (abortSignal.aborted) onAbort();
128
158
  else abortSignal.addEventListener('abort', onAbort, { once: true });
129
159
  }
160
+ const deadlineAt = Date.now() + SHELL_ADMISSION_WAIT_MS;
130
161
  const deadline = setTimeout(() => {
131
162
  try { ctl.abort(_admissionSaturationError(admission, SHELL_ADMISSION_WAIT_MS)); } catch {}
132
163
  }, SHELL_ADMISSION_WAIT_MS);
133
164
  if (deadline.unref) deadline.unref();
134
165
  try {
135
- const lease = await admission.acquire('shell', { signal: ctl.signal, label });
136
- // Hand governance back to the caller's signal: the internal deadline
137
- // controller may still fire in a lost race after grant, and a stale
138
- // aborted signal on the lease would poison later parent-restore paths.
139
- lease.signal = abortSignal || null;
140
- return lease;
166
+ for (;;) {
167
+ try {
168
+ const lease = await admission.acquire('shell', { signal: ctl.signal, label, dependency });
169
+ // Hand governance back to the caller's signal: the internal deadline
170
+ // controller may still fire in a lost race after grant, and a stale
171
+ // aborted signal on the lease would poison later parent-restore paths.
172
+ lease.signal = abortSignal || null;
173
+ return lease;
174
+ } catch (err) {
175
+ const remainingMs = deadlineAt - Date.now();
176
+ if (!_isMemoryPressureError(err) || ctl.signal.aborted || remainingMs <= 1_200) throw err;
177
+ await _abortableDelay(Math.min(1_000, remainingMs), ctl.signal);
178
+ }
179
+ }
141
180
  } finally {
142
181
  clearTimeout(deadline);
143
182
  if (abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch {} }
@@ -320,7 +359,7 @@ export function execShellCommand({
320
359
  let _markerFilters = null;
321
360
  let _onChildErrorRef = null;
322
361
  try {
323
- resourceLease = await _acquireShellLeaseBounded(admission, {
362
+ resourceLease = await acquireShellLeaseBounded(admission, {
324
363
  abortSignal,
325
364
  label: String(command || '').slice(0, 120),
326
365
  });