mixdog 0.9.23 → 0.9.25
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.
- package/package.json +2 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/tool-smoke.mjs +38 -30
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +246 -47
- package/src/tui/engine/agent-job-feed.mjs +47 -3
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/src/runtime/channels/lib/seat-lock.mjs +0 -196
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { statSync } from 'fs';
|
|
2
2
|
import { assertPathReachable, assertPathsReachable } from './fs-reachability.mjs';
|
|
3
3
|
import { isAbsolute, resolve } from 'path';
|
|
4
|
+
import { WRAPPER_NAMES } from '../shell-policy.mjs';
|
|
4
5
|
import {
|
|
5
6
|
cwdRelativePath,
|
|
6
7
|
normalizeInputPath,
|
|
@@ -651,3 +652,63 @@ export function foregroundLongCommandHint(command, timeoutMs, args = {}) {
|
|
|
651
652
|
if (!longTimeout && !watchLike && !longSleep) return '';
|
|
652
653
|
return 'Error: long foreground command detected.';
|
|
653
654
|
}
|
|
655
|
+
|
|
656
|
+
// Commands that must NOT be promoted to background on a foreground timeout —
|
|
657
|
+
// they run in the foreground and are killed at their deadline instead. Mirrors
|
|
658
|
+
// the reference CLI DISALLOWED_AUTO_BACKGROUND_COMMANDS: `sleep` on posix,
|
|
659
|
+
// `start-sleep`/`sleep` (the PS built-in alias) on PowerShell. A `sleep`/
|
|
660
|
+
// `Start-Sleep` the caller wants to survive must be launched with
|
|
661
|
+
// run_in_background/async explicitly.
|
|
662
|
+
const _DISALLOWED_AUTO_BACKGROUND_POSIX = ['sleep'];
|
|
663
|
+
const _DISALLOWED_AUTO_BACKGROUND_PS = ['start-sleep', 'sleep'];
|
|
664
|
+
|
|
665
|
+
// Whether one shell segment reaches a disallowed sleep-like command. Strips a
|
|
666
|
+
// leading subshell/paren/brace/`&`, then walks tokens skipping VAR=val and
|
|
667
|
+
// option flags (quotes stripped so `'Start-Sleep'` resolves). A disallowed base
|
|
668
|
+
// name found anywhere reachable blocks promotion. Once a command-runner wrapper
|
|
669
|
+
// (env/sudo/timeout/nice/…, shell-policy.mjs's WRAPPER_NAMES) has been seen we
|
|
670
|
+
// do NOT model that wrapper's flag arity — we keep scanning EVERY later bare
|
|
671
|
+
// token for a hidden `sleep`, so `sudo -u nobody sleep 5` / `setpriv --reuid
|
|
672
|
+
// user sleep` are caught (erring toward NOT promoting). Without a wrapper the
|
|
673
|
+
// first real command token decides the segment.
|
|
674
|
+
function _segmentDisallowed(segment, disallowed) {
|
|
675
|
+
const stripped = String(segment || '').replace(/^[\s(){}&]+/, '');
|
|
676
|
+
const tokens = stripped.split(/\s+/).filter(Boolean);
|
|
677
|
+
let sawWrapper = false;
|
|
678
|
+
for (let tok of tokens) {
|
|
679
|
+
tok = tok.replace(/^['"]+|['"]+$/g, '');
|
|
680
|
+
if (!tok) continue;
|
|
681
|
+
if (/^[A-Za-z_]\w*=/.test(tok)) continue; // VAR=val assignment
|
|
682
|
+
if (tok.startsWith('-')) continue; // option flag
|
|
683
|
+
const stem = tok.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
|
|
684
|
+
if (disallowed.includes(stem)) return true; // sleep reachable here
|
|
685
|
+
if (WRAPPER_NAMES.has(stem)) { sawWrapper = true; continue; }
|
|
686
|
+
if (/^\d+(?:\.\d+)?[smhd]?$/i.test(tok)) continue; // numeric/duration arg
|
|
687
|
+
// First real command token. Without a preceding wrapper it IS the base
|
|
688
|
+
// command and it wasn't disallowed → segment is safe. With a wrapper,
|
|
689
|
+
// this may be a wrapper value arg (`sudo -u nobody …`); keep scanning
|
|
690
|
+
// for a hidden sleep instead of stopping here.
|
|
691
|
+
if (!sawWrapper) return false;
|
|
692
|
+
}
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Whether a still-running foreground one-shot may be promoted to a background
|
|
697
|
+
// job when it hits its timeout (CC isAutobackgroundingAllowed analogue).
|
|
698
|
+
// Scans EVERY segment of a &&/||/;/|/& chain and, per segment, peels wrappers
|
|
699
|
+
// before reading the base command — so a sleep-like hidden behind a chain
|
|
700
|
+
// (`cd x && sleep 5`) or a runner wrapper (`timeout 5 sleep`, `env sleep`) is
|
|
701
|
+
// still caught. Errs toward NOT promoting: any disallowed base command in any
|
|
702
|
+
// segment blocks promotion. shellType 'powershell' uses the PS disallow list.
|
|
703
|
+
export function isAutobackgroundingAllowed(command, shellType) {
|
|
704
|
+
const cmd = String(command || '').trim();
|
|
705
|
+
if (!cmd) return true;
|
|
706
|
+
const disallowed = shellType === 'powershell'
|
|
707
|
+
? _DISALLOWED_AUTO_BACKGROUND_PS
|
|
708
|
+
: _DISALLOWED_AUTO_BACKGROUND_POSIX;
|
|
709
|
+
const segments = cmd.split(/(?:&&|\|\||[;\n|&])/);
|
|
710
|
+
for (const seg of segments) {
|
|
711
|
+
if (_segmentDisallowed(seg, disallowed)) return false;
|
|
712
|
+
}
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
@@ -402,6 +402,11 @@ export function startBackgroundShellJob({ command, timeoutMs, workDir, mergeStde
|
|
|
402
402
|
// watcher never keeps the host process alive.
|
|
403
403
|
const SHELL_JOB_WATCH_POLL_MS = 2000;
|
|
404
404
|
const SHELL_JOB_WATCH_GRACE_MS = 5000;
|
|
405
|
+
// 32-bit timer ceiling: setTimeout delays above 2^31-1 wrap to a tiny value
|
|
406
|
+
// and fire immediately. The resolved timeout is already clamped at parse time
|
|
407
|
+
// (bash-tool.mjs TIMER_MAX_MS), but sites that add grace to it can still exceed
|
|
408
|
+
// the ceiling, so clamp the summed delay here too.
|
|
409
|
+
const TIMER_MAX_MS = 2_147_483_647;
|
|
405
410
|
// Registry of armed background-job watchers keyed by jobId. task wait
|
|
406
411
|
// and `kill` actions already hold the completed outcome, so they cancel the
|
|
407
412
|
// armed watcher here to prevent a double-notify when its next poll fires.
|
|
@@ -653,11 +658,16 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
|
|
|
653
658
|
} catch { watcher = null; }
|
|
654
659
|
pollTimer = setInterval(() => checkDone('poll'), SHELL_JOB_WATCH_POLL_MS);
|
|
655
660
|
if (typeof pollTimer.unref === 'function') pollTimer.unref();
|
|
656
|
-
const startedAtMs = Date.parse(readShellJobDetail(jobId)?.startedAt || '') || Date.now();
|
|
657
661
|
const timeoutMs = Number(readShellJobDetail(jobId)?.timeoutMs || 0);
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
662
|
+
// Only arm the hard-stop when a timeout is enforced. timeoutMs<=0 means
|
|
663
|
+
// unlimited: arming it would fire ~grace ms after arm and mark the job
|
|
664
|
+
// failed. fs.watch + poll remain the completion paths for such jobs.
|
|
665
|
+
if (timeoutMs > 0) {
|
|
666
|
+
const startedAtMs = Date.parse(readShellJobDetail(jobId)?.startedAt || '') || Date.now();
|
|
667
|
+
const hardStopMs = Math.min(TIMER_MAX_MS, Math.max(0, (startedAtMs + timeoutMs + SHELL_JOB_WATCH_GRACE_MS) - Date.now()));
|
|
668
|
+
hardStopTimer = setTimeout(() => fire('timeout'), hardStopMs);
|
|
669
|
+
if (typeof hardStopTimer.unref === 'function') hardStopTimer.unref();
|
|
670
|
+
}
|
|
661
671
|
} catch (err) {
|
|
662
672
|
cleanup();
|
|
663
673
|
try { process.stderr.write(`[shell-jobs] watchBackgroundShellJob arm failed: jobId=${jobId} err=${err?.message ?? String(err)}\n`); } catch { /* ignore */ }
|
|
@@ -755,7 +765,10 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
|
|
|
755
765
|
const stderrPath = shellJobStderrPath(jobId);
|
|
756
766
|
const exitPath = shellJobExitPath(jobId);
|
|
757
767
|
const donePath = shellJobDonePath(jobId);
|
|
758
|
-
|
|
768
|
+
// timeoutMs <= 0 means unlimited (async omitted default): no kernel
|
|
769
|
+
// `timeout` wrapper, no enforced marker — exec the inner shell directly.
|
|
770
|
+
const enforceTimeout = Number(timeoutMs) > 0;
|
|
771
|
+
const timeoutSeconds = enforceTimeout ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 0;
|
|
759
772
|
// P2 fix: wrap with POSIX `timeout` so the kernel terminates the process
|
|
760
773
|
// at deadline even if the JS-side timer is interrupted. --preserve-status
|
|
761
774
|
// keeps the user command's exit code on success; on timeout the wrapper
|
|
@@ -787,7 +800,11 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
|
|
|
787
800
|
// status for processes that actually exited 0. `rm -- "$0"` removes
|
|
788
801
|
// the staged wrapper .cmd.sh after donePath is published so a host
|
|
789
802
|
// crash before this point still leaves the file for the sweep to GC.
|
|
790
|
-
const
|
|
803
|
+
const _innerRun = `${innerShellQ} ${innerArgsQ} ${userCmdQuoted}`;
|
|
804
|
+
const _execPart = enforceTimeout
|
|
805
|
+
? `if command -v timeout >/dev/null 2>&1; then _to=timeout; elif command -v gtimeout >/dev/null 2>&1; then _to=gtimeout; else _to=; fi; if [ -n "$_to" ]; then touch ${shellQuoteSingle(enforcedPath)}; "$_to" ${timeoutSeconds} ${_innerRun}; else ${_innerRun}; fi`
|
|
806
|
+
: _innerRun;
|
|
807
|
+
const wrapped = `{ ${_execPart}; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
|
|
791
808
|
// Stage the wrapped command to a .sh and let the script open its own
|
|
792
809
|
// output files via `exec > … 2> …`. The parent does NOT pass file
|
|
793
810
|
// descriptors via stdio inheritance (`stdio: 'ignore'` for all three).
|
|
@@ -841,8 +858,13 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
|
|
|
841
858
|
startedAt: new Date().toISOString(),
|
|
842
859
|
};
|
|
843
860
|
writeShellJobDetail(detail);
|
|
844
|
-
|
|
845
|
-
|
|
861
|
+
// Deadline cleanup poke only when a timeout is enforced; an unlimited
|
|
862
|
+
// (timeoutMs<=0) job has no deadline — completion is observed via the
|
|
863
|
+
// fs.watch/poll watcher and refreshShellJob on task queries.
|
|
864
|
+
if (enforceTimeout) {
|
|
865
|
+
const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
|
|
866
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
867
|
+
}
|
|
846
868
|
return detail;
|
|
847
869
|
}
|
|
848
870
|
|
|
@@ -876,7 +898,10 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
|
|
|
876
898
|
`$exitPath = ${psSingleQuote(exitPath)}`,
|
|
877
899
|
`$donePath = ${psSingleQuote(donePath)}`,
|
|
878
900
|
`$mergeStderr = ${mergeLiteral}`,
|
|
879
|
-
|
|
901
|
+
// 0 (or negative) = unlimited: the wrapper's `if ($timeoutMs -gt 0 ...`
|
|
902
|
+
// guard falls through to a plain WaitForExit() with no deadline. Do NOT
|
|
903
|
+
// floor to 1 — that would enforce a 1ms timeout on unlimited jobs.
|
|
904
|
+
`$timeoutMs = ${Math.max(0, Math.floor(timeoutMs || 0))}`,
|
|
880
905
|
'$code = 1',
|
|
881
906
|
'try {',
|
|
882
907
|
// -ExecutionPolicy Bypass: unlike -EncodedCommand, -File is subject to
|
|
@@ -986,21 +1011,23 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
|
|
|
986
1011
|
pid: childPid,
|
|
987
1012
|
mergeStderr,
|
|
988
1013
|
timeoutMs,
|
|
989
|
-
timeoutSeconds: Math.max(1, Math.ceil(timeoutMs / 1000)),
|
|
1014
|
+
timeoutSeconds: Number(timeoutMs) > 0 ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 0,
|
|
990
1015
|
stdoutPath,
|
|
991
1016
|
stderrPath: mergeStderr ? stdoutPath : rawStderrPath,
|
|
992
1017
|
exitPath,
|
|
993
1018
|
donePath,
|
|
994
|
-
// The PS wrapper enforces timeoutMs
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
timeoutEnforced:
|
|
1019
|
+
// The PS wrapper enforces the deadline (WaitForExit($timeoutMs) →
|
|
1020
|
+
// Stop-Process → 124) only when timeoutMs>0; an unlimited job waits
|
|
1021
|
+
// with no deadline, so don't falsely claim enforcement.
|
|
1022
|
+
timeoutEnforced: Number(timeoutMs) > 0,
|
|
998
1023
|
// Per-terminal session stamp (see resolveJobOwnerHostPid).
|
|
999
1024
|
ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
|
|
1000
1025
|
startedAt: new Date().toISOString(),
|
|
1001
1026
|
};
|
|
1002
1027
|
writeShellJobDetail(detail);
|
|
1003
|
-
|
|
1004
|
-
|
|
1028
|
+
if (Number(timeoutMs) > 0) {
|
|
1029
|
+
const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
|
|
1030
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1031
|
+
}
|
|
1005
1032
|
return detail;
|
|
1006
1033
|
}
|
|
@@ -180,13 +180,61 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
if (mode === 'dependents') {
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
nativePatchStr,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|