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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +12 -8
  3. package/scripts/bench/cache-probe-tasks.json +8 -0
  4. package/scripts/tool-smoke.mjs +109 -0
  5. package/src/defaults/mixdog-config.template.json +1 -0
  6. package/src/mixdog-session-runtime.mjs +8 -0
  7. package/src/runtime/agent/orchestrator/config.mjs +30 -0
  8. package/src/runtime/agent/orchestrator/context/collect.mjs +160 -0
  9. package/src/runtime/agent/orchestrator/mcp/client.mjs +25 -1
  10. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +24 -9
  11. package/src/runtime/agent/orchestrator/providers/gemini.mjs +18 -2
  12. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -0
  13. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +79 -0
  14. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  15. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +9 -2
  16. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -3
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -5
  18. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +1 -22
  19. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +26 -3
  20. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +29 -1
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +30 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +333 -14
  26. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +129 -2
  27. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +58 -18
  28. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +56 -83
  29. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +26 -56
  30. package/src/runtime/channels/lib/webhook/deliveries.mjs +4 -1
  31. package/src/session-runtime/settings-api.mjs +13 -0
  32. package/src/session-runtime/tool-catalog.mjs +34 -7
  33. package/src/standalone/agent-tool/render.mjs +2 -0
  34. package/src/standalone/agent-tool.mjs +28 -6
  35. package/src/standalone/memory-runtime-proxy.mjs +85 -21
  36. package/src/tui/App.jsx +20 -1
  37. package/src/tui/app/extension-pickers.mjs +6 -3
  38. package/src/tui/dist/index.mjs +28 -4
  39. 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 { collectSkillsCached, loadSkillResource, buildSkillToolEnvelope } from '../../context/collect.mjs';
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 { collectSkillsCached, buildSkillManifest, composeSystemPrompt } from '../context/collect.mjs';
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) ? [] : collectSkillsCached(opts.cwd);
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 ? [] : collectSkillsCached(session.cwd);
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 already accepts that shape via coerceShapeFlex(),
372
- // but validation runs first; apply the same lossless shape coercion here so
373
- // valid batched reads do not waste a retry turn.
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 = coerceShapeFlex(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, sep } from 'node:path';
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) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(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) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(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) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(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 { findFileByBasename, findBySuffixStrip } from './path-diagnostics.mjs';
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
- // Hallucinated absolute-prefix recovery first: no directory walk,
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 = coerceShapeFlex(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
- const suffixHit = findBySuffixStrip(workDir, missingPath);
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.`;