mixdog 0.9.10 → 0.9.11
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 +1 -1
- package/scripts/agent-tag-reuse-smoke.mjs +12 -8
- package/scripts/bench/cache-probe-tasks.json +8 -0
- package/scripts/tool-smoke.mjs +109 -0
- package/src/defaults/mixdog-config.template.json +1 -0
- package/src/mixdog-session-runtime.mjs +8 -0
- package/src/runtime/agent/orchestrator/config.mjs +30 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +160 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +25 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +24 -9
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +18 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +79 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +1 -22
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +26 -3
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +29 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +30 -4
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +333 -14
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +129 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +58 -18
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +56 -83
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +26 -56
- package/src/runtime/channels/lib/webhook/deliveries.mjs +4 -1
- package/src/session-runtime/settings-api.mjs +13 -0
- package/src/session-runtime/tool-catalog.mjs +34 -7
- package/src/standalone/agent-tool/render.mjs +2 -0
- package/src/standalone/agent-tool.mjs +28 -6
- package/src/standalone/memory-runtime-proxy.mjs +85 -21
- package/src/tui/App.jsx +20 -1
- package/src/tui/app/extension-pickers.mjs +6 -3
- package/src/tui/dist/index.mjs +28 -4
- package/src/tui/engine.mjs +2 -0
|
@@ -66,11 +66,30 @@ export function xaiResponsesCacheRouting(opts, params, rawTools, model) {
|
|
|
66
66
|
const providerKey = resolveProviderCacheKey(opts, 'xai');
|
|
67
67
|
const prefixSeed = xaiPrefixSeed({ opts, params, rawTools, model });
|
|
68
68
|
const prefixHash = traceHash(prefixSeed);
|
|
69
|
+
// Optional lane sharding (OpenAI prompt-caching guidance: keep each
|
|
70
|
+
// prefix+prompt_cache_key combo under ~15 RPM). When lane shards are
|
|
71
|
+
// enabled (MIXDOG_XAI_RESPONSES_CACHE_LANE_SHARDS=N or lane auto), mix the
|
|
72
|
+
// assigned slot into the routing seed so sessions spread across N server
|
|
73
|
+
// cache keys instead of overflowing one. Slot is derived from a hash of
|
|
74
|
+
// the session id (NOT in-process round-robin: headless workers run one
|
|
75
|
+
// session per process, where round-robin degenerates to slot 0 for
|
|
76
|
+
// everyone). Default stays a single shared key (lane disabled) —
|
|
77
|
+
// identical seed/key to before.
|
|
78
|
+
const lane = xaiResponsesPromptCacheLane(opts, null, { prefixHash, ownerSessionHash: sessionId ? traceHash(sessionId) : null });
|
|
79
|
+
const laneEnabled = lane?.enabled === true;
|
|
80
|
+
const laneShards = Number.isFinite(Number(lane?.shards)) && Number(lane.shards) > 0 ? Number(lane.shards) : 0;
|
|
81
|
+
const explicitSlot = Number(opts?.promptCacheLaneSlot ?? opts?.xaiCacheLaneSlot);
|
|
82
|
+
const laneSlot = Number.isFinite(explicitSlot) && explicitSlot >= 0
|
|
83
|
+
? (laneShards > 0 ? Math.floor(explicitSlot) % laneShards : Math.floor(explicitSlot))
|
|
84
|
+
: (laneShards > 0
|
|
85
|
+
? createHash('sha256').update(sessionId || String(process.pid)).digest().readUInt32BE(0) % laneShards
|
|
86
|
+
: Number.isFinite(Number(lane?.slot)) ? Number(lane.slot) : 0);
|
|
69
87
|
const routingSeed = stableTraceStringify({
|
|
70
88
|
scope: 'xai-responses-prefix-v1',
|
|
71
89
|
providerKey: String(providerKey),
|
|
72
90
|
model: model || null,
|
|
73
91
|
prefixHash,
|
|
92
|
+
...(laneEnabled ? { laneSlot } : {}),
|
|
74
93
|
});
|
|
75
94
|
return {
|
|
76
95
|
key: deterministicUuidFromKey(routingSeed),
|
|
@@ -78,6 +97,10 @@ export function xaiResponsesCacheRouting(opts, params, rawTools, model) {
|
|
|
78
97
|
seedHash: traceHash(routingSeed),
|
|
79
98
|
prefixHash,
|
|
80
99
|
ownerSessionHash: sessionId ? traceHash(sessionId) : null,
|
|
100
|
+
...(laneEnabled ? {
|
|
101
|
+
laneIndex: laneSlot,
|
|
102
|
+
activeLanes: Number.isFinite(Number(lane?.shards)) && Number(lane.shards) > 0 ? Number(lane.shards) : null,
|
|
103
|
+
} : {}),
|
|
81
104
|
};
|
|
82
105
|
}
|
|
83
106
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Deferred catalog call-through: inactive catalog tool_use → discovery bookkeeping
|
|
2
|
+
// then normal executeTool routing (runtime errors only; no pre-dispatch schema).
|
|
3
|
+
import { clean } from '../../../../../session-runtime/session-text.mjs';
|
|
4
|
+
import { isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
|
|
5
|
+
|
|
6
|
+
/** Skill-list plumbing only; mutation/MCP/builtins must use the catalog+mode gate. */
|
|
7
|
+
const INACTIVE_INFRA_BYPASS = new Set(['skills_list', 'skill_view']);
|
|
8
|
+
|
|
9
|
+
function isActiveSessionTool(session, name) {
|
|
10
|
+
const key = clean(name);
|
|
11
|
+
if (!key || !Array.isArray(session?.tools)) return false;
|
|
12
|
+
return session.tools.some((tool) => clean(tool?.name) === key);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function lookupDeferredCatalogTool(session, name) {
|
|
16
|
+
const catalog = Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : [];
|
|
17
|
+
const key = clean(name);
|
|
18
|
+
if (!key) return null;
|
|
19
|
+
for (const tool of catalog) {
|
|
20
|
+
const n = clean(tool?.name);
|
|
21
|
+
if (n === key || n.toLowerCase() === key.toLowerCase()) return tool;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isOnDeferredToolSurface(session, name) {
|
|
27
|
+
if (!session) return false;
|
|
28
|
+
return isActiveSessionTool(session, name) || lookupDeferredCatalogTool(session, name) !== null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toolExecutesWhenInactive(name) {
|
|
32
|
+
return INACTIVE_INFRA_BYPASS.has(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveDeferredSelectMode(session) {
|
|
36
|
+
const spec = session?.toolSpec;
|
|
37
|
+
if (spec == null || spec === '') return null;
|
|
38
|
+
if (spec === 'readonly') return 'readonly';
|
|
39
|
+
if (spec === 'full' || spec === 'mcp') return 'full';
|
|
40
|
+
if (Array.isArray(spec)) {
|
|
41
|
+
if (!spec.length) return null;
|
|
42
|
+
if (spec.includes('full')) return 'full';
|
|
43
|
+
if (spec.includes('tools:readonly')) return 'readonly';
|
|
44
|
+
return 'full';
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function denyDeferredCallThrough(message) {
|
|
50
|
+
return { deny: message };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Inactive deferred catalog hits: readonly/mode gate + promoteToActive, or deny.
|
|
55
|
+
* Returns null when not applicable (not in catalog, already active, infra allowlist).
|
|
56
|
+
*/
|
|
57
|
+
export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
|
|
58
|
+
if (!sessionRef) return null;
|
|
59
|
+
const tool = lookupDeferredCatalogTool(sessionRef, name);
|
|
60
|
+
if (!tool) return null;
|
|
61
|
+
if (isActiveSessionTool(sessionRef, name)) return null;
|
|
62
|
+
if (toolExecutesWhenInactive(name)) return null;
|
|
63
|
+
|
|
64
|
+
const toolLabel = clean(tool.name) || clean(name) || 'tool';
|
|
65
|
+
const resolvedMode = resolveDeferredSelectMode(sessionRef);
|
|
66
|
+
if (resolvedMode === null) {
|
|
67
|
+
if (!isReadonlySelectable(tool)) {
|
|
68
|
+
return denyDeferredCallThrough(
|
|
69
|
+
`Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode; use tool_search or load it explicitly.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else if (resolvedMode === 'readonly' && !isReadonlySelectable(tool)) {
|
|
73
|
+
return denyDeferredCallThrough(`Error: tool "${toolLabel}" is not available in readonly mode`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const selectMode = resolvedMode === null ? 'readonly' : resolvedMode;
|
|
77
|
+
selectDeferredTools(sessionRef, [toolLabel], selectMode, { promoteToActive: true });
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// shell/apply_patch/builtin/external-adapter) through before/after hooks and the
|
|
4
4
|
// scoped-cache outcome bookkeeping. No behavior change: bodies are verbatim from
|
|
5
5
|
// loop.mjs, re-exported via the facade so existing importers keep working.
|
|
6
|
-
import { executeMcpTool, isMcpTool, mcpToolHasField } from '../../mcp/client.mjs';
|
|
6
|
+
import { executeMcpTool, isMcpTool, isRegisteredMcpTool, mcpToolHasField } from '../../mcp/client.mjs';
|
|
7
7
|
import { executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool, isExternalAdapterTool } from '../../tools/builtin.mjs';
|
|
8
8
|
import { executeBashSessionTool } from '../../tools/bash-session.mjs';
|
|
9
9
|
import { executePatchTool } from '../../tools/patch.mjs';
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
buildAgentBashSessionArgs,
|
|
22
22
|
resolvePreToolAskApproval,
|
|
23
23
|
} from './tool-helpers.mjs';
|
|
24
|
+
import { isOnDeferredToolSurface, prepareDeferredToolCallThrough } from './deferred-call-through.mjs';
|
|
24
25
|
|
|
25
26
|
let codeGraphRuntimePromise = null;
|
|
26
27
|
async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
|
|
@@ -125,6 +126,8 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
|
|
|
125
126
|
const afterToolHook = typeof executeOpts.afterToolHook === 'function'
|
|
126
127
|
? executeOpts.afterToolHook
|
|
127
128
|
: sessionRef?.afterToolHook;
|
|
129
|
+
const deferredPrep = prepareDeferredToolCallThrough(sessionRef, name, args);
|
|
130
|
+
if (deferredPrep?.deny) return deferredPrep.deny;
|
|
128
131
|
const __result = await (async () => {
|
|
129
132
|
if (name === 'Skill') {
|
|
130
133
|
return viewSkill(cwd, args?.name);
|
|
@@ -136,6 +139,9 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
|
|
|
136
139
|
return viewSkill(cwd, args?.name);
|
|
137
140
|
}
|
|
138
141
|
if (isMcpTool(name)) {
|
|
142
|
+
if (!isOnDeferredToolSurface(sessionRef, name) && !isRegisteredMcpTool(name)) {
|
|
143
|
+
return formatUnknownBuiltinToolMessage(name, args, 'tool');
|
|
144
|
+
}
|
|
139
145
|
// 24h trace data shows ~24% of external MCP calls are cwd-sensitive
|
|
140
146
|
// (bash / grep / read / list / glob etc.) but the worker session's
|
|
141
147
|
// cwd was previously dropped here. Inject cwd only when the tool's
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
import { isMcpTool } from '../../mcp/client.mjs';
|
|
5
5
|
import { isBuiltinTool } from '../../tools/builtin.mjs';
|
|
6
6
|
import { isInternalTool } from '../../internal-tools.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
collectSkillsCached,
|
|
9
|
+
loadSkillResource,
|
|
10
|
+
buildSkillToolEnvelope,
|
|
11
|
+
filterSkillsExcludingDisabled,
|
|
12
|
+
isSkillDisabled,
|
|
13
|
+
} from '../../context/collect.mjs';
|
|
8
14
|
import { isAgentOwner } from '../../agent-owner.mjs';
|
|
9
15
|
|
|
10
16
|
// Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
|
|
@@ -37,12 +43,13 @@ export function getToolKind(name) {
|
|
|
37
43
|
return 'builtin';
|
|
38
44
|
}
|
|
39
45
|
export function buildSkillsListResponse(cwd) {
|
|
40
|
-
const skills = collectSkillsCached(cwd);
|
|
46
|
+
const skills = filterSkillsExcludingDisabled(collectSkillsCached(cwd));
|
|
41
47
|
const entries = skills.map(s => ({ name: s.name, description: s.description || '' }));
|
|
42
48
|
return JSON.stringify({ skills: entries });
|
|
43
49
|
}
|
|
44
50
|
export function viewSkill(cwd, name) {
|
|
45
51
|
if (!name) return 'Error: skill name is required';
|
|
52
|
+
if (isSkillDisabled(name)) return `Error: skill "${name}" is disabled`;
|
|
46
53
|
const res = loadSkillResource(name, cwd);
|
|
47
54
|
if (!res) return `Error: skill "${name}" not found`;
|
|
48
55
|
// Return the general tool envelope: the model-visible tool_result is the
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from './compact.mjs';
|
|
14
14
|
import { estimateMessagesTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
|
|
15
15
|
import { executeInternalTool } from '../internal-tools.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { collectPromptSkillsCached, buildSkillManifest, composeSystemPrompt } from '../context/collect.mjs';
|
|
17
17
|
import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, bumpSessionGeneration, setLiveSession } from './store.mjs';
|
|
18
18
|
import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
|
|
19
19
|
import { clearOffloadSession } from './tool-result-offload.mjs';
|
|
@@ -636,7 +636,7 @@ export function createSession(opts) {
|
|
|
636
636
|
// maintenance roles are deliberately narrowed away from the Skill tool.
|
|
637
637
|
// Do not leak a Skill manifest into those hidden prompts when no Skill()
|
|
638
638
|
// loader is available.
|
|
639
|
-
const skills = (opts.skipSkills || hiddenAgent) ? [] :
|
|
639
|
+
const skills = (opts.skipSkills || hiddenAgent) ? [] : collectPromptSkillsCached(opts.cwd);
|
|
640
640
|
|
|
641
641
|
// BP1 is shared tool policy (+ compact skill manifest in compose). BP2 is
|
|
642
642
|
// role/system rules. User-defined schedules/webhooks ride as normal user
|
|
@@ -1785,7 +1785,7 @@ export async function resumeSession(sessionId, preset) {
|
|
|
1785
1785
|
// createSession so resume and spawn produce identical BP_1 shapes.
|
|
1786
1786
|
const oldTools = session.tools || [];
|
|
1787
1787
|
const ownerIsAgent = isAgentOwner(session);
|
|
1788
|
-
const skills = ownerIsAgent ? [] :
|
|
1788
|
+
const skills = ownerIsAgent ? [] : collectPromptSkillsCached(session.cwd);
|
|
1789
1789
|
let toolSpec = ownerIsAgent ? 'full' : (preset || session.preset || 'full');
|
|
1790
1790
|
if (session.profileId && _agentRuntimeApi?.getProfile) {
|
|
1791
1791
|
try {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// callers may pass either spelling (e.g. `glob` alias for grep, or
|
|
10
10
|
// `file_path` alias for read.path).
|
|
11
11
|
|
|
12
|
-
import { coerceShapeFlex, hasGlobMagic } from './path-utils.mjs';
|
|
12
|
+
import { coerceReadFamilyPathArg, coerceShapeFlex, hasGlobMagic } from './path-utils.mjs';
|
|
13
13
|
|
|
14
14
|
const MAX_INT = 100000;
|
|
15
15
|
// Explicit grep context should be large enough to frame a function/block without
|
|
@@ -368,11 +368,11 @@ function guardRead(a) {
|
|
|
368
368
|
return 'Error: read requires "path" (or alias file_path).';
|
|
369
369
|
}
|
|
370
370
|
// Some providers/models send a batched path array as a JSON string despite
|
|
371
|
-
// the schema. The executor
|
|
372
|
-
//
|
|
373
|
-
//
|
|
371
|
+
// the schema (or path:"[]" meaning cwd). The executor coerces via
|
|
372
|
+
// coerceReadFamilyPathArg(); mirror that here so validation does not reject
|
|
373
|
+
// shapes the executor would absorb.
|
|
374
374
|
if (hasOwn(a, 'path')) {
|
|
375
|
-
a.path =
|
|
375
|
+
a.path = coerceReadFamilyPathArg(a.path);
|
|
376
376
|
}
|
|
377
377
|
// path can be string | string[] | object[]; file_path is string
|
|
378
378
|
if (hasOwn(a, 'path')) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// adapter expects — the caller (builtin.mjs default: case) falls back to
|
|
13
13
|
// the existing EXTERNAL_TOOL_REDIRECTS guidance message in that case.
|
|
14
14
|
import { readFileSync, mkdirSync, existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
15
|
-
import { dirname
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
16
|
import { atomicWrite } from './atomic-write.mjs';
|
|
17
17
|
import { assertPathsReachable } from './fs-reachability.mjs';
|
|
18
18
|
import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath } from './path-utils.mjs';
|
|
@@ -102,25 +102,6 @@ function guardRealTarget(fullPath) {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
// Write containment — mirrors apply_patch's realBase check: the REAL resolved
|
|
106
|
-
// target (symlinks flattened, create-mode suffix re-attached lexically) must
|
|
107
|
-
// stay inside the REAL workDir. A lexically-inside path whose ancestor
|
|
108
|
-
// symlink/junction lands outside the project must not be writable through
|
|
109
|
-
// this adapter surface (apply_patch refuses the same shape).
|
|
110
|
-
function guardBaseContainment(fullPath, workDir) {
|
|
111
|
-
let realBase;
|
|
112
|
-
try { realBase = realpathSync(workDir); } catch { return null; }
|
|
113
|
-
const nearest = realpathNearestExisting(fullPath);
|
|
114
|
-
if (!nearest) return null;
|
|
115
|
-
const realResolved = nearest.real + fullPath.slice(nearest.probe.length);
|
|
116
|
-
const fold = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
|
|
117
|
-
const baseWithSep = realBase.endsWith(sep) ? realBase : realBase + sep;
|
|
118
|
-
if (fold(realResolved) !== fold(realBase) && !fold(realResolved).startsWith(fold(baseWithSep))) {
|
|
119
|
-
return `Error: cannot write outside the working directory: ${normalizeOutputPath(realResolved)} escapes ${normalizeOutputPath(realBase)}`;
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
105
|
async function resolveTargetPath(args, workDir) {
|
|
125
106
|
const raw = args?.path ?? args?.file_path;
|
|
126
107
|
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
@@ -138,8 +119,6 @@ async function resolveTargetPath(args, workDir) {
|
|
|
138
119
|
catch (e) { return { error: `Error: ${e?.message || e}` }; }
|
|
139
120
|
const realGuardErr = guardRealTarget(full);
|
|
140
121
|
if (realGuardErr) return { error: realGuardErr };
|
|
141
|
-
const containErr = guardBaseContainment(full, workDir);
|
|
142
|
-
if (containErr) return { error: containErr };
|
|
143
122
|
return { full };
|
|
144
123
|
}
|
|
145
124
|
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { readdirSync } from 'fs';
|
|
2
2
|
import { basename, relative } from 'path';
|
|
3
3
|
import {
|
|
4
|
+
coerceReadFamilyPathArg,
|
|
4
5
|
extractGlobBaseDirectory,
|
|
5
6
|
hasGlobMagic,
|
|
6
7
|
normalizeInputPath,
|
|
7
8
|
normalizeOutputPath,
|
|
8
9
|
resolveAgainstCwd,
|
|
9
10
|
} from './path-utils.mjs';
|
|
11
|
+
import { tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
|
|
10
12
|
import { normalizeErrorMessage } from './path-diagnostics.mjs';
|
|
11
13
|
import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component } from './device-paths.mjs';
|
|
12
14
|
import {
|
|
@@ -37,6 +39,19 @@ const LIST_WALK_TIMEOUT_MS = 20_000;
|
|
|
37
39
|
const LIST_ABSOLUTE_CAP = 50_000;
|
|
38
40
|
|
|
39
41
|
/** undefined / invalid / negative → defaultCap; 0 = no page cap (absolute caps still apply). */
|
|
42
|
+
async function readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, rerunTool) {
|
|
43
|
+
const redirected = await tryReadFamilyEnoentRedirect({
|
|
44
|
+
workDir,
|
|
45
|
+
resolvedPath: fullPath,
|
|
46
|
+
requestedPath: inputPath,
|
|
47
|
+
errCode: err?.code,
|
|
48
|
+
options,
|
|
49
|
+
rerun: (target, opts) => rerunTool({ ...args, path: target }, workDir, opts),
|
|
50
|
+
});
|
|
51
|
+
if (redirected) return redirected;
|
|
52
|
+
return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
function normalizeListHeadLimit(raw, defaultCap) {
|
|
41
56
|
if (raw === undefined || raw === null || raw === '') return defaultCap;
|
|
42
57
|
const n = Number(raw);
|
|
@@ -60,6 +75,7 @@ function listGuardPath(p) {
|
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
export async function executeListTool(args, workDir, options = {}) {
|
|
78
|
+
args.path = coerceReadFamilyPathArg(args.path, workDir);
|
|
63
79
|
if (Array.isArray(args.path)) {
|
|
64
80
|
const list = [...new Set(args.path.map((p) => (typeof p === 'string' ? p.trim() : '')).filter((p) => p.length > 0))];
|
|
65
81
|
const capped = list.length > 10;
|
|
@@ -120,7 +136,9 @@ export async function executeListTool(args, workDir, options = {}) {
|
|
|
120
136
|
catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
|
|
121
137
|
let st;
|
|
122
138
|
try { st = getCachedReadOnlyStat(fullPath); }
|
|
123
|
-
catch (err) {
|
|
139
|
+
catch (err) {
|
|
140
|
+
return await readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, executeListTool);
|
|
141
|
+
}
|
|
124
142
|
if (!st.isDirectory()) return `Error: not a directory — ${normalizeOutputPath(fullPath)}`;
|
|
125
143
|
|
|
126
144
|
const rows = [];
|
|
@@ -251,7 +269,9 @@ export async function executeTreeTool(args, workDir, options = {}) {
|
|
|
251
269
|
catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
|
|
252
270
|
let st;
|
|
253
271
|
try { st = getCachedReadOnlyStat(fullPath); }
|
|
254
|
-
catch (err) {
|
|
272
|
+
catch (err) {
|
|
273
|
+
return await readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, executeListTool);
|
|
274
|
+
}
|
|
255
275
|
if (!st.isDirectory()) return `Error: not a directory — ${normalizeOutputPath(fullPath)}`;
|
|
256
276
|
const lines = [`${normalizeOutputPath(fullPath)}/`];
|
|
257
277
|
const prefixStack = [''];
|
|
@@ -407,6 +427,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
|
407
427
|
}
|
|
408
428
|
|
|
409
429
|
export async function executeFindFilesTool(args, workDir, options = {}) {
|
|
430
|
+
args.path = coerceReadFamilyPathArg(args.path, workDir);
|
|
410
431
|
args.path = normalizeInputPath(args.path);
|
|
411
432
|
let inputPath = args.path || '.';
|
|
412
433
|
let namePattern = typeof args.name === 'string' ? args.name : null;
|
|
@@ -511,7 +532,9 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
|
|
|
511
532
|
catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
|
|
512
533
|
let rootStat;
|
|
513
534
|
try { rootStat = getCachedReadOnlyStat(fullPath); }
|
|
514
|
-
catch (err) {
|
|
535
|
+
catch (err) {
|
|
536
|
+
return await readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, executeFindFilesTool);
|
|
537
|
+
}
|
|
515
538
|
if (!rootStat.isDirectory()) return `Error: not a directory — ${normalizeOutputPath(fullPath)}`;
|
|
516
539
|
|
|
517
540
|
const matches = [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from 'os';
|
|
2
2
|
import { isAbsolute, relative, resolve } from 'path';
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { isWSL } from '../../../../shared/wsl.mjs';
|
|
5
5
|
|
|
6
6
|
// Restore the on-disk casing of a path (win32 only). rg relativizes candidate
|
|
@@ -237,3 +237,31 @@ export function coerceShapeFlex(value) {
|
|
|
237
237
|
}
|
|
238
238
|
return value;
|
|
239
239
|
}
|
|
240
|
+
|
|
241
|
+
// JSON-stringified path arrays (path:"[]") and empty arrays mean "search cwd".
|
|
242
|
+
// Bracket-shaped strings that exist on disk (e.g. a literal `[x]` directory) are
|
|
243
|
+
// left untouched — JSON reinterpretation only runs after a stat miss.
|
|
244
|
+
export function coerceReadFamilyPathArg(path, workDir = null) {
|
|
245
|
+
if (path === undefined || path === null || path === '') return path;
|
|
246
|
+
if (typeof path === 'string' && typeof workDir === 'string' && workDir) {
|
|
247
|
+
const trimmed = path.trim();
|
|
248
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
249
|
+
try {
|
|
250
|
+
const resolved = resolveAgainstCwd(normalizeInputPath(trimmed), workDir);
|
|
251
|
+
statSync(resolved);
|
|
252
|
+
return path;
|
|
253
|
+
} catch { /* not a literal bracket path — allow JSON coercion */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const coerced = coerceShapeFlex(path);
|
|
257
|
+
if (!Array.isArray(coerced)) return coerced;
|
|
258
|
+
const list = coerced
|
|
259
|
+
// Region objects ({path,offset,limit}) must pass through untouched —
|
|
260
|
+
// read's batch dispatcher consumes them. Only strings are trimmed.
|
|
261
|
+
.map((p) => (typeof p === 'string' ? p.trim() : p))
|
|
262
|
+
.filter((p) => (typeof p === 'string' ? p.length > 0 : (p && typeof p === 'object')));
|
|
263
|
+
if (list.length === 0) return '.';
|
|
264
|
+
// Collapse to scalar only for a lone string; a lone region object must
|
|
265
|
+
// stay an array so read's object-batch dispatcher handles it.
|
|
266
|
+
return list.length === 1 && typeof list[0] === 'string' ? list[0] : list;
|
|
267
|
+
}
|
|
@@ -3,7 +3,7 @@ import * as fsPromises from 'fs/promises';
|
|
|
3
3
|
import { readFile } from 'fs/promises';
|
|
4
4
|
import { extname } from 'path';
|
|
5
5
|
import { normalizeInputPath } from './path-utils.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { buildNotFoundHint, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
|
|
7
7
|
import { getReadSnapshot } from './read-snapshot-runtime.mjs';
|
|
8
8
|
import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
|
|
9
9
|
import { formatBinaryReadPreview } from './binary-file.mjs';
|
|
@@ -202,24 +202,25 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
202
202
|
}
|
|
203
203
|
if (!st) {
|
|
204
204
|
const err = _statErr;
|
|
205
|
+
const redirected = await tryReadFamilyEnoentRedirect({
|
|
206
|
+
workDir,
|
|
207
|
+
resolvedPath: fullPath,
|
|
208
|
+
requestedPath: filePath,
|
|
209
|
+
errCode: err?.code,
|
|
210
|
+
options,
|
|
211
|
+
rerun: (target, opts) => executeSingleReadTool(
|
|
212
|
+
{ ...args, path: target },
|
|
213
|
+
workDir,
|
|
214
|
+
readStateScope,
|
|
215
|
+
opts,
|
|
216
|
+
helpers,
|
|
217
|
+
),
|
|
218
|
+
});
|
|
219
|
+
if (redirected) return redirected;
|
|
205
220
|
const similar = findSimilarFile(fullPath);
|
|
206
221
|
let hint = similar ? ` Did you mean "${normalizeOutputPath(similar)}"?` : '';
|
|
207
|
-
// Right-name / wrong-directory miss: findSimilarFile only checks the
|
|
208
|
-
// same dir. Locate the basename elsewhere in the tree and name the real
|
|
209
|
-
// path(s) directly — the route a model would otherwise reconstruct with
|
|
210
|
-
// a grep/glob storm.
|
|
211
222
|
if (!similar) {
|
|
212
|
-
|
|
213
|
-
// stats directly (finds vendor/ paths the basename BFS skips).
|
|
214
|
-
const suffixHit = findBySuffixStrip(workDir, fullPath);
|
|
215
|
-
if (suffixHit) {
|
|
216
|
-
hint = ` Not found at this path; the same filename exists at: "${normalizeOutputPath(suffixHit)}". Read that path directly.`;
|
|
217
|
-
} else {
|
|
218
|
-
const elsewhere = findFileByBasename(workDir, fullPath);
|
|
219
|
-
if (elsewhere.length) {
|
|
220
|
-
hint = ` Not found at this path; the same filename exists at: ${elsewhere.map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. Read that path directly.`;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
+
hint = buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
|
|
223
224
|
}
|
|
224
225
|
const _rawMsg = err instanceof Error ? err.message : String(err);
|
|
225
226
|
const _safeMsg = normalizeErrorMessage(_rawMsg, workDir);
|
|
@@ -5,6 +5,7 @@ import { readEntryCoalescedDiskWindow } from './read-batch.mjs';
|
|
|
5
5
|
import { readPathStringGuardError } from './read-open.mjs';
|
|
6
6
|
import { parseReadLineNumberArg } from './read-args.mjs';
|
|
7
7
|
import { assertPathsReachable } from './fs-reachability.mjs';
|
|
8
|
+
import { coerceReadFamilyPathArg } from './path-utils.mjs';
|
|
8
9
|
|
|
9
10
|
function hasLineCoordinate(path) {
|
|
10
11
|
return typeof path === 'string' && /(?:#L\d+|:\d+(?:-\d+)?(?::|$))/i.test(path);
|
|
@@ -109,7 +110,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
109
110
|
args.offset = Math.trunc(ccOffset) - 1;
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
|
-
args.path =
|
|
113
|
+
args.path = coerceReadFamilyPathArg(args.path, workDir);
|
|
113
114
|
// Reachability preflight up front (all shapes) — before readPathStringGuardError /
|
|
114
115
|
// image stat, all of which can touch sync FS.
|
|
115
116
|
{
|
|
@@ -30,6 +30,7 @@ export function buildGrepCacheKey(parts) {
|
|
|
30
30
|
fileType,
|
|
31
31
|
onlyMatching,
|
|
32
32
|
pcre2,
|
|
33
|
+
withFilename,
|
|
33
34
|
} = parts;
|
|
34
35
|
return [
|
|
35
36
|
'grep',
|
|
@@ -48,6 +49,7 @@ export function buildGrepCacheKey(parts) {
|
|
|
48
49
|
onlyMatching ? 'o1' : 'o0',
|
|
49
50
|
Array.isArray(fileType) ? fileType.join('\x01') : (fileType || ''),
|
|
50
51
|
pcre2 ? 'p1' : 'p0',
|
|
52
|
+
withFilename ? 'H1' : 'H0',
|
|
51
53
|
].join('|');
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -67,6 +69,7 @@ export function buildGrepRgArgs(parts) {
|
|
|
67
69
|
onlyMatching,
|
|
68
70
|
fixedStrings = false,
|
|
69
71
|
pcre2 = false,
|
|
72
|
+
withFilename = false,
|
|
70
73
|
} = parts;
|
|
71
74
|
// `--hidden`: search dotfiles/dot-dirs (.github, .mixdog) that
|
|
72
75
|
// rg skips by default. The DEFAULT_IGNORE_GLOBS below still exclude .git and
|
|
@@ -78,6 +81,7 @@ export function buildGrepRgArgs(parts) {
|
|
|
78
81
|
rgArgs.push('--count');
|
|
79
82
|
} else {
|
|
80
83
|
rgArgs.push('--no-heading');
|
|
84
|
+
if (withFilename) rgArgs.push('-H');
|
|
81
85
|
if (showLineNumbers) rgArgs.push('--line-number');
|
|
82
86
|
if (beforeN !== null) rgArgs.push('-B', String(beforeN));
|
|
83
87
|
if (afterN !== null) rgArgs.push('-A', String(afterN));
|
|
@@ -64,12 +64,38 @@ export function stripEmbeddedPathQuotes(p) {
|
|
|
64
64
|
// not-found codes: EACCES/EPERM etc. keep their real failure semantics and
|
|
65
65
|
// must not trigger same-basename guidance or the BFS scan.
|
|
66
66
|
const NOT_FOUND_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
67
|
+
export function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT') {
|
|
68
|
+
if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
|
|
69
|
+
const suffixHit = findBySuffixStrip(workDir, missingPath);
|
|
70
|
+
if (suffixHit) return suffixHit;
|
|
71
|
+
const elsewhere = findFileByBasename(workDir, missingPath);
|
|
72
|
+
if (elsewhere.length === 1) return elsewhere[0];
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function redirectedFromPrefix(requestedPath) {
|
|
77
|
+
return `[redirected from ${normalizeOutputPath(requestedPath)}]\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function tryReadFamilyEnoentRedirect({
|
|
81
|
+
workDir,
|
|
82
|
+
resolvedPath,
|
|
83
|
+
requestedPath,
|
|
84
|
+
errCode,
|
|
85
|
+
options,
|
|
86
|
+
rerun,
|
|
87
|
+
}) {
|
|
88
|
+
if (options?._enoentRedirectFrom) return null;
|
|
89
|
+
const target = resolveUniqueEnoentRedirect(workDir, resolvedPath, errCode);
|
|
90
|
+
if (!target) return null;
|
|
91
|
+
const body = await rerun(target, { ...options, _enoentRedirectFrom: resolvedPath });
|
|
92
|
+
const shown = requestedPath ?? resolvedPath;
|
|
93
|
+
return redirectedFromPrefix(shown) + body;
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'ENOENT') {
|
|
68
97
|
if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return '';
|
|
69
|
-
|
|
70
|
-
if (suffixHit) {
|
|
71
|
-
return ` Not found at this path; the same filename exists at: "${normalizeOutputPath(suffixHit)}". ${actionVerb} that path directly.`;
|
|
72
|
-
}
|
|
98
|
+
if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode)) return '';
|
|
73
99
|
const elsewhere = findFileByBasename(workDir, missingPath);
|
|
74
100
|
if (elsewhere.length) {
|
|
75
101
|
return ` Not found at this path; the same filename exists at: ${elsewhere.map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. ${actionVerb} that path directly.`;
|