mixdog 0.9.2 → 0.9.3

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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -181,9 +181,9 @@ export const PROVIDER_WS_ACQUIRE_TIMEOUT_MS = resolveTimeoutMs(
181
181
  { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
182
182
  );
183
183
 
184
- // Single inter-chunk idle timer, matching codex's DEFAULT_STREAM_IDLE_TIMEOUT_MS
185
- // (300s). codex resets one idle timer on every received WS frame; mixdog does
186
- // the same (openai-oauth-ws messageHandler resets on every parsed event). There
184
+ // Single inter-chunk idle timer (300s), matching the upstream WS provider's
185
+ // default stream idle timeout. Mixdog resets one idle timer on every received
186
+ // WS frame (openai-oauth-ws messageHandler resets on every parsed event). There
187
187
  // is deliberately no separate "first-meaningful" watchdog — a live stream
188
188
  // (including server-side reasoning ACKed via response.created) keeps this timer
189
189
  // fresh, and only true socket silence trips it. Env-tunable.
@@ -17,7 +17,7 @@ export const BUILTIN_TOOLS = [
17
17
  name: 'read',
18
18
  title: 'Mixdog Read',
19
19
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
20
- description: 'Read known file path(s). Prefer grep content_with_context or code_graph anchors first. Window with numeric offset+limit only. Batch paths/regions as real arrays. Dirs use list.',
20
+ description: 'Read known file path(s). Prefer grep content_with_context or code_graph anchors first. Window with numeric offset+limit only. Batch paths/regions as real arrays; adjacent spans in one file = one window, not repeated calls. Dirs use list.',
21
21
  inputSchema: {
22
22
  type: 'object',
23
23
  properties: {
@@ -42,7 +42,7 @@ export const BUILTIN_TOOLS = [
42
42
  minItems: 1,
43
43
  },
44
44
  ],
45
- description: 'File path, path[], or {path,offset,limit}[] region objects. Pass arrays directly; JSON strings are legacy recovery only. Dirs use list.',
45
+ description: 'File path, path[], or {path,offset,limit}[] region objects. Pass arrays directly; JSON strings are legacy recovery only.',
46
46
  },
47
47
  offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip before reading; 0 starts at line 1. Continue with offset:N.' },
48
48
  limit: { type: 'number', minimum: 1, description: 'Numeric max lines to return after offset.' },
@@ -0,0 +1,241 @@
1
+ // Native adapters for well-known tool names from OTHER agent CLIs whose
2
+ // argument shapes differ from any mixdog builtin (StrReplace/Write/bash
3
+ // variants). Executed directly against the filesystem via atomicWrite / the
4
+ // existing shell runner — NOT by synthesizing an apply_patch V4A string
5
+ // (that approach was tried and abandoned: building a correct V4A envelope
6
+ // from arbitrary old_string/new_string/contents blew up in edge-case
7
+ // complexity for no benefit over a direct fs edit).
8
+ //
9
+ // Contract: tryExecuteExternalToolAdapter(name, args, workDir, options)
10
+ // returns a result STRING when the call was handled (success or a concrete
11
+ // tool-level error), or `null` when the args shape didn't match what the
12
+ // adapter expects — the caller (builtin.mjs default: case) falls back to
13
+ // the existing EXTERNAL_TOOL_REDIRECTS guidance message in that case.
14
+ import { readFileSync, mkdirSync, existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
15
+ import { dirname, sep } from 'node:path';
16
+ import { atomicWrite } from './atomic-write.mjs';
17
+ import { assertPathsReachable } from './fs-reachability.mjs';
18
+ import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath } from './path-utils.mjs';
19
+ import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component, isBlockedDevicePath, isSpecialFileStat } from './device-paths.mjs';
20
+ import { executeBashTool } from './bash-tool.mjs';
21
+ import { invalidateBuiltinResultCache } from './cache-layers.mjs';
22
+ import { markCodeGraphDirtyPaths } from '../code-graph-state.mjs';
23
+
24
+ const STR_REPLACE_NAMES = new Set(['strreplace', 'str_replace', 'str_replace_editor', 'search_replace']);
25
+ const WRITE_NAMES = new Set(['write', 'create_file', 'createfile']);
26
+ // 'bash'/'Bash' explicitly request the posix/git-bash shell kind; the
27
+ // run/runcommand/terminal/run_terminal_cmd family leaves `shell` unset so
28
+ // executeBashTool's own default-shell resolution applies (mirrors how the
29
+ // native `shell` tool behaves when the caller omits `shell`).
30
+ const BASH_SHELL_KIND_NAMES = new Set(['bash']);
31
+ const BASH_DEFAULT_NAMES = new Set(['run', 'runcommand', 'terminal', 'run_terminal_cmd']);
32
+
33
+ /**
34
+ * True when `name` is a foreign-CLI tool this module can adapt natively.
35
+ * Used by the session loop's dispatch so these names route INTO
36
+ * executeBuiltinTool (whose default: case invokes the adapter) instead of
37
+ * short-circuiting to the unknown-tool redirect message.
38
+ */
39
+ export function isExternalAdapterTool(name) {
40
+ if (typeof name !== 'string' || !name) return false;
41
+ const key = name.toLowerCase();
42
+ return STR_REPLACE_NAMES.has(key) || WRITE_NAMES.has(key)
43
+ || BASH_SHELL_KIND_NAMES.has(key) || BASH_DEFAULT_NAMES.has(key);
44
+ }
45
+
46
+ // Same write-target guards the read/list surfaces enforce (UNC → NTLM hash
47
+ // leak, device namespace → raw device access / hang, trailing-dot / ADS →
48
+ // device-guard bypass). Writes are strictly more dangerous than reads, so
49
+ // every adapter write path must run these on both the normalized input and
50
+ // the fully resolved path. Returns an Error string or null.
51
+ function guardWritePath(p) {
52
+ if (isUncPath(p))
53
+ return `Error: cannot write UNC / SMB path (network credential leak risk): ${normalizeOutputPath(p)}`;
54
+ if (isWindowsDevicePath(p))
55
+ return `Error: cannot write Windows device path (reserved name or raw-device namespace): ${normalizeOutputPath(p)}`;
56
+ if (hasUnsafeWin32Component(p))
57
+ return `Error: cannot write Windows path with trailing dot/space or NTFS ADS suffix (bypasses device guard): ${normalizeOutputPath(p)}`;
58
+ if (isBlockedDevicePath(p))
59
+ return `Error: cannot write device file (would block or corrupt a device): ${normalizeOutputPath(p)}`;
60
+ return null;
61
+ }
62
+
63
+ // Realpath to the nearest existing ancestor (create-mode leaves don't exist
64
+ // yet). Returns { probe, real } or null when nothing on the path exists /
65
+ // realpath itself fails.
66
+ function realpathNearestExisting(fullPath) {
67
+ let probe = fullPath;
68
+ while (probe && !existsSync(probe)) {
69
+ const parent = dirname(probe);
70
+ if (!parent || parent === probe) return null;
71
+ probe = parent;
72
+ }
73
+ if (!probe || !existsSync(probe)) return null;
74
+ try { return { probe, real: realpathSync(probe) }; } catch { return null; }
75
+ }
76
+
77
+ // Realpath-based guard: a symlink/junction in the target (or its nearest
78
+ // existing ancestor, for create-mode paths) can point at a UNC share or a
79
+ // device namespace that the lexical checks above never see. Mirrors the
80
+ // realpath verification apply_patch runs on every header. Returns an Error
81
+ // string or null; never throws.
82
+ function guardRealTarget(fullPath) {
83
+ const nearest = realpathNearestExisting(fullPath);
84
+ if (nearest && nearest.real !== nearest.probe) {
85
+ const guardErr = guardWritePath(nearest.real);
86
+ if (guardErr) return `${guardErr} (symlink target of ${normalizeOutputPath(nearest.probe)})`;
87
+ }
88
+ try {
89
+ const lst = lstatSync(fullPath);
90
+ if (lst.isSymbolicLink()) {
91
+ const realTarget = realpathSync(fullPath);
92
+ const linkGuardErr = guardWritePath(realTarget);
93
+ if (linkGuardErr) return `${linkGuardErr} (symlink target of ${normalizeOutputPath(fullPath)})`;
94
+ }
95
+ // statSync FOLLOWS a leaf symlink, so a link pointing at a custom
96
+ // FIFO/socket/char/block inode trips here too (lstat alone only saw
97
+ // the link inode itself).
98
+ const st = statSync(fullPath);
99
+ if (isSpecialFileStat(st))
100
+ return `Error: cannot write special file (FIFO / character / block device / socket): ${normalizeOutputPath(fullPath)}`;
101
+ } catch { /* ENOENT (create mode) — nothing to check */ }
102
+ return null;
103
+ }
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
+ async function resolveTargetPath(args, workDir) {
125
+ const raw = args?.path ?? args?.file_path;
126
+ if (typeof raw !== 'string' || raw.length === 0) return null;
127
+ const norm = normalizeInputPath(raw);
128
+ const guardErr = guardWritePath(norm);
129
+ if (guardErr) return { error: guardErr };
130
+ const full = resolveAgainstCwd(norm, workDir);
131
+ const fullGuardErr = guardWritePath(full);
132
+ if (fullGuardErr) return { error: fullGuardErr };
133
+ // Reachability preflight BEFORE any sync fs (existsSync/realpathSync/
134
+ // lstatSync in the guards below): a dead mount would wedge the event loop
135
+ // on the first sync stat, defeating every downstream timeout. Same
136
+ // deadline-raced probe the read path runs (_readReachPreflight).
137
+ try { await assertPathsReachable([full]); }
138
+ catch (e) { return { error: `Error: ${e?.message || e}` }; }
139
+ const realGuardErr = guardRealTarget(full);
140
+ if (realGuardErr) return { error: realGuardErr };
141
+ const containErr = guardBaseContainment(full, workDir);
142
+ if (containErr) return { error: containErr };
143
+ return { full };
144
+ }
145
+
146
+ function invalidateAfterWrite(fullPath) {
147
+ try { invalidateBuiltinResultCache([fullPath]); } catch { /* best-effort */ }
148
+ try { markCodeGraphDirtyPaths([fullPath]); } catch { /* best-effort */ }
149
+ }
150
+
151
+ function countOccurrences(haystack, needle) {
152
+ let count = 0;
153
+ let idx = -1;
154
+ while ((idx = haystack.indexOf(needle, idx + 1)) !== -1) count += 1;
155
+ return count;
156
+ }
157
+
158
+ async function adaptStrReplace(args, workDir, options) {
159
+ const oldStr = args?.old_string;
160
+ const newStr = args?.new_string;
161
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
162
+ // Empty old_string would loop forever in countOccurrences (indexOf('', i)
163
+ // never returns -1) and has no meaningful replace semantics anyway.
164
+ if (oldStr.length === 0) return 'Error: old_string must be a non-empty string';
165
+ const target = await resolveTargetPath(args, workDir);
166
+ if (!target) return null;
167
+ if (target.error) return target.error;
168
+ const fullPath = target.full;
169
+ let content;
170
+ let statBefore = null;
171
+ try {
172
+ try { statBefore = statSync(fullPath); } catch { /* readFileSync below surfaces the real error */ }
173
+ content = readFileSync(fullPath, 'utf8');
174
+ } catch (err) {
175
+ return `Error: cannot read ${fullPath} (${err?.message || err})`;
176
+ }
177
+ const matchCount = countOccurrences(content, oldStr);
178
+ if (matchCount === 0) return `Error: old_string not found in ${fullPath}`;
179
+ if (matchCount > 1) return `Error: old_string is ambiguous: ${matchCount} matches in ${fullPath}`;
180
+ const first = content.indexOf(oldStr);
181
+ const updated = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
182
+ // Lost-update guard: hand the pre-read stat to atomicWrite as an expected
183
+ // target snapshot — it re-stats immediately before EACH rename attempt and
184
+ // aborts with ESTALE_TARGET when another writer (parallel tool call,
185
+ // interleaved apply_patch) changed the file after our read. This closes
186
+ // the check-then-write race a pre-write mtime comparison here would leave.
187
+ try {
188
+ await atomicWrite(fullPath, updated, {
189
+ sessionId: options?.readStateScope || options?.sessionId,
190
+ expectedTargetSnapshot: statBefore ? {
191
+ exists: true,
192
+ size: statBefore.size,
193
+ mtimeMs: statBefore.mtimeMs,
194
+ ctimeMs: statBefore.ctimeMs,
195
+ ino: statBefore.ino,
196
+ } : undefined,
197
+ });
198
+ } catch (err) {
199
+ if (err?.code === 'ESTALE_TARGET') {
200
+ return `Error: ${fullPath} changed on disk during the replace; re-read and retry`;
201
+ }
202
+ throw err;
203
+ }
204
+ invalidateAfterWrite(fullPath);
205
+ return `Updated ${fullPath} (1 replacement)`;
206
+ }
207
+
208
+ async function adaptWrite(args, workDir, options) {
209
+ const contents = args?.contents ?? args?.content ?? args?.file_text;
210
+ if (typeof contents !== 'string') return null;
211
+ const target = await resolveTargetPath(args, workDir);
212
+ if (!target) return null;
213
+ if (target.error) return target.error;
214
+ const fullPath = target.full;
215
+ const existed = existsSync(fullPath);
216
+ try { mkdirSync(dirname(fullPath), { recursive: true }); } catch { /* best-effort: parent may already exist */ }
217
+ await atomicWrite(fullPath, contents, { sessionId: options?.readStateScope || options?.sessionId });
218
+ invalidateAfterWrite(fullPath);
219
+ return `${existed ? 'Updated' : 'Created'} ${fullPath} (${Buffer.byteLength(contents, 'utf8')} bytes)`;
220
+ }
221
+
222
+ async function adaptBash(key, args, workDir, options) {
223
+ if (typeof args?.command !== 'string' || args.command.length === 0) return null;
224
+ const shellArgs = { ...args };
225
+ if (BASH_SHELL_KIND_NAMES.has(key) && shellArgs.shell === undefined) shellArgs.shell = 'bash';
226
+ return executeBashTool(shellArgs, workDir, options);
227
+ }
228
+
229
+ export async function tryExecuteExternalToolAdapter(name, args, workDir, options) {
230
+ if (typeof name !== 'string' || !name) return null;
231
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
232
+ const key = name.toLowerCase();
233
+ try {
234
+ if (STR_REPLACE_NAMES.has(key)) return await adaptStrReplace(args, workDir, options);
235
+ if (WRITE_NAMES.has(key)) return await adaptWrite(args, workDir, options);
236
+ if (BASH_SHELL_KIND_NAMES.has(key) || BASH_DEFAULT_NAMES.has(key)) return await adaptBash(key, args, workDir, options);
237
+ } catch (err) {
238
+ return `Error: ${name} failed (${err?.message || String(err)})`;
239
+ }
240
+ return null;
241
+ }
@@ -0,0 +1,162 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { tryExecuteExternalToolAdapter, isExternalAdapterTool } from './external-tool-adapters.mjs';
7
+
8
+ function makeDir() {
9
+ return mkdtempSync(join(tmpdir(), 'ext-adapter-test-'));
10
+ }
11
+
12
+ test('isExternalAdapterTool recognizes adapted names only', () => {
13
+ assert.equal(isExternalAdapterTool('StrReplace'), true);
14
+ assert.equal(isExternalAdapterTool('str_replace'), true);
15
+ assert.equal(isExternalAdapterTool('str_replace_editor'), true);
16
+ assert.equal(isExternalAdapterTool('search_replace'), true);
17
+ assert.equal(isExternalAdapterTool('Write'), true);
18
+ assert.equal(isExternalAdapterTool('create_file'), true);
19
+ assert.equal(isExternalAdapterTool('Bash'), true);
20
+ assert.equal(isExternalAdapterTool('run_terminal_cmd'), true);
21
+ // Destructive / different-shape names stay on the redirect path.
22
+ assert.equal(isExternalAdapterTool('Delete'), false);
23
+ assert.equal(isExternalAdapterTool('delete_file'), false);
24
+ assert.equal(isExternalAdapterTool('edit'), false);
25
+ assert.equal(isExternalAdapterTool('multiedit'), false);
26
+ assert.equal(isExternalAdapterTool(''), false);
27
+ assert.equal(isExternalAdapterTool(null), false);
28
+ });
29
+
30
+ test('StrReplace: unique match replaces content', async () => {
31
+ const dir = makeDir();
32
+ const file = join(dir, 'a.txt');
33
+ writeFileSync(file, 'alpha\nbeta\ngamma\n');
34
+ const out = await tryExecuteExternalToolAdapter('StrReplace', {
35
+ path: file, old_string: 'beta', new_string: 'BETA',
36
+ }, dir, {});
37
+ assert.match(out, /^Updated .*\(1 replacement\)$/);
38
+ assert.equal(readFileSync(file, 'utf8'), 'alpha\nBETA\ngamma\n');
39
+ });
40
+
41
+ test('StrReplace: zero matches errors without writing', async () => {
42
+ const dir = makeDir();
43
+ const file = join(dir, 'a.txt');
44
+ writeFileSync(file, 'alpha\n');
45
+ const out = await tryExecuteExternalToolAdapter('str_replace', {
46
+ path: file, old_string: 'missing', new_string: 'x',
47
+ }, dir, {});
48
+ assert.match(out, /old_string not found/);
49
+ assert.equal(readFileSync(file, 'utf8'), 'alpha\n');
50
+ });
51
+
52
+ test('StrReplace: ambiguous matches error with count', async () => {
53
+ const dir = makeDir();
54
+ const file = join(dir, 'a.txt');
55
+ writeFileSync(file, 'dup\ndup\n');
56
+ const out = await tryExecuteExternalToolAdapter('search_replace', {
57
+ path: file, old_string: 'dup', new_string: 'x',
58
+ }, dir, {});
59
+ assert.match(out, /ambiguous: 2 matches/);
60
+ assert.equal(readFileSync(file, 'utf8'), 'dup\ndup\n');
61
+ });
62
+
63
+ test('StrReplace: missing old_string falls back (null)', async () => {
64
+ const dir = makeDir();
65
+ const out = await tryExecuteExternalToolAdapter('StrReplace', {
66
+ path: join(dir, 'a.txt'), new_string: 'x',
67
+ }, dir, {});
68
+ assert.equal(out, null);
69
+ });
70
+
71
+ test('StrReplace: unreadable target is a concrete error', async () => {
72
+ const dir = makeDir();
73
+ const out = await tryExecuteExternalToolAdapter('StrReplace', {
74
+ path: join(dir, 'nope.txt'), old_string: 'a', new_string: 'b',
75
+ }, dir, {});
76
+ assert.match(out, /^Error: cannot read /);
77
+ });
78
+
79
+ test('Write: creates a new file with contents', async () => {
80
+ const dir = makeDir();
81
+ const file = join(dir, 'new', 'nested.txt');
82
+ const out = await tryExecuteExternalToolAdapter('Write', {
83
+ path: file, contents: 'created body',
84
+ }, dir, {});
85
+ assert.match(out, /^Created .*nested\.txt \(12 bytes\)$/);
86
+ assert.equal(readFileSync(file, 'utf8'), 'created body');
87
+ });
88
+
89
+ test('Write: overwrites an existing file and says Updated', async () => {
90
+ const dir = makeDir();
91
+ const file = join(dir, 'exists.txt');
92
+ writeFileSync(file, 'old');
93
+ const out = await tryExecuteExternalToolAdapter('write', {
94
+ file_path: file, content: 'new body',
95
+ }, dir, {});
96
+ assert.match(out, /^Updated /);
97
+ assert.equal(readFileSync(file, 'utf8'), 'new body');
98
+ });
99
+
100
+ test('Write: missing contents falls back to redirect (null)', async () => {
101
+ const dir = makeDir();
102
+ const out = await tryExecuteExternalToolAdapter('Write', {
103
+ path: join(dir, 'x.txt'),
104
+ }, dir, {});
105
+ assert.equal(out, null);
106
+ assert.equal(existsSync(join(dir, 'x.txt')), false);
107
+ });
108
+
109
+ test('StrReplace: missing old_string/new_string falls back (null)', async () => {
110
+ const dir = makeDir();
111
+ const file = join(dir, 'a.txt');
112
+ writeFileSync(file, 'body');
113
+ const out = await tryExecuteExternalToolAdapter('StrReplace', {
114
+ path: file, old_string: 'body',
115
+ }, dir, {});
116
+ assert.equal(out, null);
117
+ });
118
+
119
+ test('StrReplace: empty old_string is a concrete error (no hang)', async () => {
120
+ const dir = makeDir();
121
+ const file = join(dir, 'a.txt');
122
+ writeFileSync(file, 'body');
123
+ const out = await tryExecuteExternalToolAdapter('StrReplace', {
124
+ path: file, old_string: '', new_string: 'x',
125
+ }, dir, {});
126
+ assert.match(out, /old_string must be a non-empty string/);
127
+ assert.equal(readFileSync(file, 'utf8'), 'body');
128
+ });
129
+
130
+ test('bash family: missing command falls back (null)', async () => {
131
+ const dir = makeDir();
132
+ const out = await tryExecuteExternalToolAdapter('Bash', {}, dir, {});
133
+ assert.equal(out, null);
134
+ });
135
+
136
+ test('Write: target outside workDir is refused (base containment)', async () => {
137
+ const workDir = makeDir();
138
+ const outsideDir = makeDir();
139
+ const outside = join(outsideDir, 'escape.txt');
140
+ const out = await tryExecuteExternalToolAdapter('Write', {
141
+ path: outside, contents: 'should not land',
142
+ }, workDir, {});
143
+ assert.match(out, /cannot write outside the working directory/);
144
+ assert.equal(existsSync(outside), false);
145
+ });
146
+
147
+ test('non-adapted names return null without touching fs', async () => {
148
+ const dir = makeDir();
149
+ const out = await tryExecuteExternalToolAdapter('Delete', {
150
+ path: join(dir, 'x.txt'),
151
+ }, dir, {});
152
+ assert.equal(out, null);
153
+ });
154
+
155
+ test('bash family: executes a command via the shell runner', async () => {
156
+ const dir = makeDir();
157
+ const out = await tryExecuteExternalToolAdapter('run_terminal_cmd', {
158
+ command: 'node -e "console.log(\'adapter-ok\')"',
159
+ }, dir, {});
160
+ assert.equal(typeof out, 'string');
161
+ assert.match(out, /adapter-ok/);
162
+ });
@@ -1,5 +1,5 @@
1
1
  // fuzzy-match.mjs — lightweight subsequence fuzzy scorer for filename / path
2
- // search (codex file-search / nucleo style, in JS). Returns a score where a
2
+ // search. Returns a score where a
3
3
  // higher value is a better match, or null when the query is not a subsequence
4
4
  // of the candidate.
5
5
  //
@@ -1,5 +1,5 @@
1
- import { readdirSync } from 'fs';
2
- import { basename, dirname, extname, isAbsolute, join, relative } from 'path';
1
+ import { readdirSync, statSync } from 'fs';
2
+ import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { resolvePluginData, mixdogRoot } from '../../../../shared/plugin-paths.mjs';
5
5
 
@@ -80,6 +80,46 @@ export function findFileByBasename(searchRoot, fullPath, { limit = 3, maxDirs =
80
80
  } catch { return []; }
81
81
  }
82
82
 
83
+ // Recover a hallucinated absolute-path PREFIX: models frequently request
84
+ // paths like /Users/foo/Local/Project/ink/src/tui/input-editing.mjs where the
85
+ // TAIL (src/tui/input-editing.mjs) is the real repo-relative path but the
86
+ // leading segments are an invented (or wrong-machine) prefix. findFileByBasename
87
+ // only matches the final basename and walks the whole tree (skipping vendor/
88
+ // noise dirs for performance); this instead peels leading segments off the
89
+ // requested path one at a time and stats the remaining tail directly against
90
+ // searchRoot — no directory walk, no skip-dir filtering, so it finds files
91
+ // under vendor/ or any other normally-skipped directory. Min tail length is 2
92
+ // segments so a bare basename (which would match almost anything) never
93
+ // counts as a hit. Capped iterations; pure best-effort, never throws.
94
+ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } = {}) {
95
+ try {
96
+ if (typeof searchRoot !== 'string' || !searchRoot) return null;
97
+ if (typeof fullPath !== 'string' || !fullPath) return null;
98
+ // Containment guard: drop `.`/`..` and drive/UNC-ish segments outright.
99
+ // Tails are joined under searchRoot; a `..` segment could stat (and
100
+ // hint) outside the repo, so no relative-traversal token may survive.
101
+ const segments = fullPath.replace(/\\/g, '/').split('/')
102
+ .filter((s) => s && s !== '.' && s !== '..' && !/^[A-Za-z]:$/.test(s));
103
+ // Peel 0..N leading segments; each peel costs one stat. maxIterations
104
+ // bounds the number of stats (strict <), and tails shorter than 2
105
+ // segments never count as a hit.
106
+ const iterations = Math.min(Math.max(segments.length - 1, 0), Math.max(maxIterations, 0));
107
+ for (let i = 0; i < iterations; i++) {
108
+ const tail = segments.slice(i);
109
+ if (tail.length < 2) break;
110
+ const candidate = join(searchRoot, ...tail);
111
+ const rel = relative(searchRoot, candidate);
112
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue;
113
+ try {
114
+ if (statSync(candidate).isFile()) {
115
+ return rel.replace(/\\/g, '/');
116
+ }
117
+ } catch { /* miss this tail length, keep peeling */ }
118
+ }
119
+ return null;
120
+ } catch { return null; }
121
+ }
122
+
83
123
  // Node's native fs errors embed the failing path wrapped in single quotes
84
124
  // using OS-native separators ('C:\\Users\\foo\\bar.mjs' on Windows). Without
85
125
  // this pass, read error bodies surface backslash paths that
@@ -20,7 +20,7 @@ export const SMART_READ_TAIL_LINES = _readEnvInt('MIXDOG_READ_TAIL_LINES', 400);
20
20
  // file actually hurts.
21
21
  export const READ_CONTEXT_ADVISORY_BYTES = 40 * 1024;
22
22
  export const READ_MAX_RENDERED_LINE_CHARS = 2_000;
23
- // Claude-Code parity: the read line-prefix separator is `→` (the `→`
23
+ // The read line-prefix separator is `→` (the `→`
24
24
  // arrow), matching default cat -n format `<n>→<content>`. It
25
25
  // MUST be a NON-WHITESPACE glyph: a tab/space separator collides with the
26
26
  // content's own leading indentation, so a model hand-reconstructing an edit
@@ -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 } from './path-diagnostics.mjs';
6
+ import { findFileByBasename, findBySuffixStrip } from './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';
@@ -209,9 +209,16 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
209
209
  // path(s) directly — the route a model would otherwise reconstruct with
210
210
  // a grep/glob storm.
211
211
  if (!similar) {
212
- const elsewhere = findFileByBasename(workDir, fullPath);
213
- if (elsewhere.length) {
214
- hint = ` Not found at this path; the same filename exists at: ${elsewhere.map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. Read that path directly.`;
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
+ }
215
222
  }
216
223
  }
217
224
  const _rawMsg = err instanceof Error ? err.message : String(err);
@@ -1,6 +1,7 @@
1
1
  import { statSync } from 'fs';
2
2
  import { isAbsolute, resolve } from 'path';
3
3
  import { trueCasePath } from './path-utils.mjs';
4
+ import { findBySuffixStrip } from './path-diagnostics.mjs';
4
5
  import {
5
6
  canonicalizeGlobSlashes,
6
7
  coerceShapeFlex,
@@ -478,6 +479,10 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
478
479
  try { grepStat = statSync(grepResolvedPath); }
479
480
  catch (err) {
480
481
  const msg = `Error: path does not exist: ${normalizeOutputPath(grepResolvedPath)} (${err?.code || 'ENOENT'})`;
482
+ const suffixHit = findBySuffixStrip(workDir, grepResolvedPath);
483
+ if (suffixHit) {
484
+ return msg + ` Not found at this path; the same filename exists at: "${normalizeOutputPath(suffixHit)}". Search that path directly.`;
485
+ }
481
486
  return msg + await _suggestIndexedPaths(grepResolvedPath, executeChildBuiltinTool, workDir);
482
487
  }
483
488
  const filenameOmitted = grepStat.isFile();