mixdog 0.9.1 → 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 (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -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
  //
@@ -5,3 +5,13 @@ export function formatMtime(mtimeMs) {
5
5
  if (!mtimeMs) return '-';
6
6
  return new Date(mtimeMs).toISOString().slice(0, 19).replace('T', ' ');
7
7
  }
8
+
9
+ // Human-readable size column for list/find. Directories have no meaningful
10
+ // byte size (render '-'); files show KB so the model can spot a large file
11
+ // before reading it. Sub-1KB files round up to 1KB.
12
+ export function formatListSize(type, bytes) {
13
+ if (type !== 'file') return '-';
14
+ const n = Number(bytes) || 0;
15
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
16
+ return `${Math.max(1, Math.round(n / 1024))}KB`;
17
+ }
@@ -26,7 +26,8 @@ import {
26
26
  NOISE_DIR_NAMES,
27
27
  walkDir,
28
28
  } from './glob-walk.mjs';
29
- import { formatMtime } from './list-formatting.mjs';
29
+ import { formatMtime, formatListSize } from './list-formatting.mjs';
30
+ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
30
31
  import { runRg } from './rg-runner.mjs';
31
32
  import { fuzzyRank } from './fuzzy-match.mjs';
32
33
  import { assertPathReachable } from './fs-reachability.mjs';
@@ -169,7 +170,7 @@ export async function executeListTool(args, workDir, options = {}) {
169
170
  }
170
171
  }
171
172
  const lines = sliced.map(r =>
172
- `${normalizeOutputPath(r.path)}\t${r.type}\t${r.size}\t${formatMtime(r.mtimeMs)}`);
173
+ `${normalizeOutputPath(r.path)}\t${r.type}\t${formatListSize(r.type, r.size)}\t${formatMtime(r.mtimeMs)}`);
173
174
  if (windowed.length > sliced.length) lines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${rows.length}; pass offset:${offset + sliced.length} to continue]`);
174
175
  if (truncatedByCap) lines.push(`... walk truncated at ${LIST_ABSOLUTE_CAP} rows or ${LIST_WALK_TIMEOUT_MS}ms timeout; narrow the path or lower depth for a complete listing`);
175
176
  let emptyMsg = '(empty directory)';
@@ -282,7 +283,7 @@ export async function executeTreeTool(args, workDir, options = {}) {
282
283
  const totalLabel = body.length >= gatherCap ? `${body.length}+` : `${body.length}`;
283
284
  outLines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${totalLabel}; pass offset:${offset + sliced.length} to continue]`);
284
285
  }
285
- const TREE_OUTPUT_CHAR_CAP = 50_000;
286
+ const TREE_OUTPUT_CHAR_CAP = TOOL_OUTPUT_MAX_BYTES;
286
287
  let out = outLines.join('\n');
287
288
  let outputCharTruncated = false;
288
289
  if (out.length > TREE_OUTPUT_CHAR_CAP) {
@@ -603,7 +604,7 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
603
604
  const windowed = offset > 0 ? matches.slice(offset) : matches;
604
605
  const sliced = headLimit > 0 ? windowed.slice(0, headLimit) : windowed;
605
606
  const lines = sliced.map(m =>
606
- `${normalizeOutputPath(m.path)}\t${m.size}\t${formatMtime(m.mtimeMs)}`);
607
+ `${normalizeOutputPath(m.path)}\t${formatListSize('file', m.size)}\t${formatMtime(m.mtimeMs)}`);
607
608
  if (windowed.length > sliced.length) lines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${matches.length}; pass offset:${offset + sliced.length} to continue]`);
608
609
  if (rgStdoutTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; results incomplete');
609
610
  if (rgStdoutPartial) lines.push('... [warning] rg exit 2 (partial results); listing may be incomplete');
@@ -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
@@ -220,5 +220,20 @@ export function coerceShapeFlex(value) {
220
220
  if (typeof parsed === 'string') return parsed;
221
221
  } catch {}
222
222
  }
223
+ // A single {path,offset,limit}-shaped object JSON string (e.g. a model
224
+ // emitting `path: "{\"path\":\"x\",\"offset\":10}"` instead of a real
225
+ // object) is a lossless single-entry batch. Wrap it in an array so the
226
+ // downstream object-array batch path (read-tool.mjs) picks it up instead
227
+ // of falling through to the plain-string path guard, which would
228
+ // misdetect the raw JSON text as a literal (and often invalid) filename.
229
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
230
+ try {
231
+ const parsed = JSON.parse(trimmed);
232
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)
233
+ && typeof (parsed.path ?? parsed.file_path) === 'string') {
234
+ return [parsed];
235
+ }
236
+ } catch {}
237
+ }
223
238
  return value;
224
239
  }
@@ -1,21 +1,11 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { normalizeInputPath, resolveAgainstCwd } from './path-utils.mjs';
3
- import { parseLineLimitArg } from './read-formatting.mjs';
4
-
5
- const READ_LINE_CONTEXT_DEFAULT = 20;
6
- const READ_LINE_CONTEXT_MAX = 200;
7
3
 
8
4
  export function parseReadLineNumberArg(value) {
9
5
  const n = Number(value);
10
6
  return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
11
7
  }
12
8
 
13
- export function parseReadLineContextArg(value) {
14
- const n = Number(value);
15
- if (!Number.isFinite(n)) return READ_LINE_CONTEXT_DEFAULT;
16
- return Math.min(READ_LINE_CONTEXT_MAX, Math.max(0, Math.trunc(n)));
17
- }
18
-
19
9
  export function parseReadPathLineSpec(rawPath) {
20
10
  if (typeof rawPath !== 'string' || rawPath.length === 0) return null;
21
11
  const text = rawPath.trim();
@@ -51,7 +41,6 @@ export function normalizePathAndStripLineCoordinate(rawPath, workDir) {
51
41
 
52
42
  export function normaliseReadLineWindowArgs(inputArgs, workDir) {
53
43
  const args = { ...inputArgs };
54
- let lineNo = parseReadLineNumberArg(args.line);
55
44
  let pathLineRange = null;
56
45
  if (typeof args.path === 'string' && args.path) {
57
46
  const spec = resolveExistingPathLineCoordinate(args.path, workDir);
@@ -61,45 +50,21 @@ export function normaliseReadLineWindowArgs(inputArgs, workDir) {
61
50
  return args;
62
51
  }
63
52
  args.path = spec.path;
64
- if (!lineNo) lineNo = spec.lineNo;
65
53
  if (spec.endLine) pathLineRange = { startLine: spec.lineNo, endLine: spec.endLine };
54
+ else pathLineRange = { startLine: spec.lineNo, endLine: spec.lineNo };
66
55
  }
67
56
  }
68
57
  const isFullMode = !args.mode || args.mode === 'full';
69
- // line= and offset= are ALTERNATIVE window anchors. Silently preferring
70
- // offset used to discard line/context and (with a bare low offset) fall
71
- // through to a whole-file smart-truncate dump — the exact read explosion
72
- // a 7-line `line+context` request was trying to avoid. Make it a hard
73
- // conflict instead.
74
- if (isFullMode && lineNo && args.offset !== undefined && args.offset !== null) {
75
- args._invertedRangeError = `Error: conflicting window args — both line (${lineNo}) and offset (${args.offset}) were given; line/context and offset/limit are alternative window forms, pass only one`;
76
- return args;
77
- }
78
- if (isFullMode && lineNo) {
79
- if (pathLineRange && args.context === undefined && (args.limit === undefined || args.limit === null)) {
80
- args.offset = Math.max(0, pathLineRange.startLine - 1);
58
+ // Public Read args are offset/limit only. Keep a narrow private
59
+ // compatibility path for file#Lx/file:line strings by converting them into
60
+ // offset/limit; do not interpret line/context fields here.
61
+ if (isFullMode && pathLineRange && args.offset === undefined) {
62
+ args.offset = Math.max(0, pathLineRange.startLine - 1);
63
+ if (args.limit === undefined) {
81
64
  args.limit = Math.max(1, pathLineRange.endLine - pathLineRange.startLine + 1);
82
- } else {
83
- const contextExplicit = args.context !== undefined && args.context !== null;
84
- const limitExplicit = args.limit !== undefined && args.limit !== null;
85
- const context = parseReadLineContextArg(args.context);
86
- if (limitExplicit && !contextExplicit) {
87
- // Explicit limit, no explicit context: anchor the window AT the
88
- // requested line so it is always included. (Was: offset centered
89
- // by the default context, which a small limit then truncated to
90
- // exclude the very line the caller asked for.)
91
- args.offset = Math.max(0, lineNo - 1);
92
- args.limit = parseLineLimitArg(args.limit, (context * 2) + 1);
93
- } else {
94
- const limit = limitExplicit
95
- ? parseLineLimitArg(args.limit, (context * 2) + 1)
96
- : (context * 2) + 1;
97
- args.offset = Math.max(0, lineNo - context - 1);
98
- args.limit = limit;
99
- }
100
65
  }
101
- delete args.line;
102
- delete args.context;
103
66
  }
67
+ delete args.line;
68
+ delete args.context;
104
69
  return args;
105
70
  }
@@ -1,4 +1,5 @@
1
1
  import { SMART_READ_MAX_BYTES } from './read-formatting.mjs';
2
+ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
2
3
 
3
4
  // Read tool caps.
4
5
  //
@@ -28,7 +29,7 @@ import { SMART_READ_MAX_BYTES } from './read-formatting.mjs';
28
29
  // cleanly without a tokenizer.
29
30
  export const READ_MAX_SIZE_BYTES = 10 * 1024 * 1024;
30
31
  export const READ_WHOLE_FILE_MAX_BYTES = 256 * 1024;
31
- export const READ_MAX_OUTPUT_BYTES = 30_000;
32
+ export const READ_MAX_OUTPUT_BYTES = TOOL_OUTPUT_MAX_BYTES;
32
33
  export const READ_STREAM_RANGE_MIN_BYTES = 128 * 1024;
33
34
  export const READ_SMART_STREAM_MIN_BYTES = SMART_READ_MAX_BYTES;
34
35
  export const READ_BATCH_RANGE_COALESCE_GAP_LINES = 256;
@@ -1,9 +1,18 @@
1
1
  import { mergeReadRanges } from './read-ranges.mjs';
2
+ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
2
3
 
3
- export const SMART_READ_MAX_BYTES = 30 * 1024;
4
- export const SMART_READ_MAX_LINES = 600;
5
- export const SMART_READ_HEAD_LINES = 200;
6
- export const SMART_READ_TAIL_LINES = 100;
4
+ // Smart-truncate cap: a no-window read returns the file until this cap, past
5
+ // which head+tail are shown and the model pages the rest with offset (footer
6
+ // says how). Byte budget is the shared TOOL_OUTPUT_MAX_BYTES; line/head/tail
7
+ // stay read-specific. Env-overridable for bench: MIXDOG_READ_MAX_LINES/_HEAD/_TAIL.
8
+ function _readEnvInt(name, fallback) {
9
+ const v = parseInt(process.env[name], 10);
10
+ return Number.isFinite(v) && v > 0 ? v : fallback;
11
+ }
12
+ export const SMART_READ_MAX_BYTES = TOOL_OUTPUT_MAX_BYTES;
13
+ export const SMART_READ_MAX_LINES = _readEnvInt('MIXDOG_READ_MAX_LINES', 2000);
14
+ export const SMART_READ_HEAD_LINES = _readEnvInt('MIXDOG_READ_HEAD_LINES', 1200);
15
+ export const SMART_READ_TAIL_LINES = _readEnvInt('MIXDOG_READ_TAIL_LINES', 400);
7
16
  // Only the genuinely large full reads warrant the anti-re-read advisory; below
8
17
  // this the smart-truncate path (30 KB) already caps normal reads, so a 16 KB
9
18
  // floor mostly fired on full:true mid-size reads where the advisory was pure
@@ -11,7 +20,7 @@ export const SMART_READ_TAIL_LINES = 100;
11
20
  // file actually hurts.
12
21
  export const READ_CONTEXT_ADVISORY_BYTES = 40 * 1024;
13
22
  export const READ_MAX_RENDERED_LINE_CHARS = 2_000;
14
- // Claude-Code parity: the read line-prefix separator is `→` (the `→`
23
+ // The read line-prefix separator is `→` (the `→`
15
24
  // arrow), matching default cat -n format `<n>→<content>`. It
16
25
  // MUST be a NON-WHITESPACE glyph: a tab/space separator collides with the
17
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);
@@ -49,8 +49,7 @@ function _guardedReadError(p, helpers) {
49
49
  }
50
50
 
51
51
  // Reachability preflight for EVERY read shape (scalar / array / reads[]). MUST
52
- // run before any sync FS — including line-coordinate disambiguation (existsSync
53
- // in readPathStringGuardError / normaliseReadLineWindowArgs) and the image
52
+ // run before any sync FS — including path normalization and the image
54
53
  // stat/read. A dead mount would otherwise freeze the event loop, defeating even
55
54
  // the 630s dispatch ceiling.
56
55
  async function _readReachPreflight(rawPath, workDir, helpers) {
@@ -58,10 +57,9 @@ async function _readReachPreflight(rawPath, workDir, helpers) {
58
57
  normalizeInputPath, resolveAgainstCwd,
59
58
  } = helpers;
60
59
  // A guarded path (UNC/SMB, Windows device, ADS, /dev/* block) must be
61
- // REJECTED here, not skipped: skipping would let the later sync existsSync
62
- // line-coordinate disambiguation (normaliseReadLineWindowArgs /
63
- // readPathStringGuardError) touch it and trigger NTLM/raw-device access or
64
- // hang. Reject up front with the same message the inline guards emit.
60
+ // REJECTED here, not skipped: skipping would let the later sync guard/open
61
+ // path touch it and trigger NTLM/raw-device access or hang. Reject up front
62
+ // with the same message the inline guards emit.
65
63
  // normalizeInputPath FIRST (FS-pure) so we stat the same path the real read
66
64
  // opens (e.g. /mnt/z/... -> Z:\...). Reachability is per-mount/dir, so the
67
65
  // line-coordinate strip only needs to land in the right directory — exact
@@ -113,7 +111,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
113
111
  }
114
112
  args.path = coerceShapeFlex(args.path);
115
113
  // Reachability preflight up front (all shapes) — before readPathStringGuardError /
116
- // normaliseReadLineWindowArgs / image stat, all of which can touch sync FS.
114
+ // image stat, all of which can touch sync FS.
117
115
  {
118
116
  const _reErr = await _readReachPreflight(args.path, workDir, helpers);
119
117
  if (_reErr) return _reErr;
@@ -186,8 +184,6 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
186
184
  }
187
185
  }
188
186
  if (r?.limit !== undefined) entry.limit = r.limit;
189
- if (r?.line !== undefined) entry.line = r.line;
190
- if (r?.context !== undefined) entry.context = r.context;
191
187
  if (r?.full !== undefined) entry.full = r.full;
192
188
  entry = normaliseReadLineWindowArgs(entry, workDir);
193
189
  return entry;
@@ -235,8 +231,6 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
235
231
  if (args.n !== undefined) entry.n = args.n;
236
232
  if (args.offset !== undefined) entry.offset = args.offset;
237
233
  if (args.limit !== undefined) entry.limit = args.limit;
238
- if (args.line !== undefined) entry.line = args.line;
239
- if (args.context !== undefined) entry.context = args.context;
240
234
  if (args.full !== undefined) entry.full = args.full;
241
235
  entry = normaliseReadLineWindowArgs(entry, workDir);
242
236
  return entry;
@@ -464,14 +458,13 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
464
458
  if (span.note) args._symbolReadNote = `symbol ${sym}: ${span.note}`;
465
459
  }
466
460
  }
467
- // A window (offset/limit/line or a path:line coordinate) beats a glance
461
+ // A window (offset/limit or a path:line coordinate) beats a glance
468
462
  // mode (head/tail/summary), which would otherwise read from a file end and
469
- // silently drop the window. Drop the glance mode BEFORE line-window
470
- // normalization so a line / path:line coordinate is actually converted to
471
- // offset/limit (normaliseReadLineWindowArgs only converts when no mode is
472
- // set). count/hex are not text-window ops and keep their mode.
463
+ // silently drop the window. Drop the glance mode BEFORE path:line
464
+ // compatibility normalization so any path coordinate is converted to
465
+ // offset/limit. count/hex are not text-window ops and keep their mode.
473
466
  {
474
- const _win = args.offset != null || args.limit != null || args.line != null || hasLineCoordinate(args.path);
467
+ const _win = args.offset != null || args.limit != null || hasLineCoordinate(args.path);
475
468
  if (_win && (args.mode === 'head' || args.mode === 'tail' || args.mode === 'summary')) {
476
469
  args = { ...args, mode: undefined };
477
470
  }
@@ -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,
@@ -36,6 +37,11 @@ import {
36
37
  import { recordReadSnapshot } from './read-snapshot-runtime.mjs';
37
38
  import { applyGrepContextLeadPolicy, GREP_CONTEXT_MAX } from './arg-guard.mjs';
38
39
 
40
+ // Default surrounding-lines window applied by output_mode:'content_with_context'
41
+ // when the caller does not pass an explicit -A/-B/-C/context. Sized to cover a
42
+ // typical function/block so a match arrives readable without a follow-up read.
43
+ const GREP_AUTO_CONTEXT_LINES = 25;
44
+
39
45
  const MIXDOG_GREP_CASE_HINT_PROBE = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_GREP_CASE_HINT_PROBE || ''));
40
46
 
41
47
  function expandLegacyEscapedAlternationPattern(rawPattern) {
@@ -353,18 +359,23 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
353
359
  .map(canonicalizeGlobSlashes)
354
360
  .filter((g) => !isRedundantAllFilesGlob(g)));
355
361
 
356
- const ALLOWED_OUTPUT_MODES = new Set(['files_with_matches', 'content', 'count']);
362
+ const ALLOWED_OUTPUT_MODES = new Set(['files_with_matches', 'content', 'content_with_context', 'count']);
357
363
  const rawOutputMode = typeof args.output_mode === 'string' ? args.output_mode.trim() : '';
358
364
  if (rawOutputMode && !ALLOWED_OUTPUT_MODES.has(rawOutputMode)) {
359
365
  return `Error: invalid output_mode ${JSON.stringify(args.output_mode)}; expected one of ${[...ALLOWED_OUTPUT_MODES].join(', ')}`;
360
366
  }
367
+ // `content_with_context` is a convenience alias for `content` that auto-applies
368
+ // a generous surrounding-lines window, so the model can read a match in place
369
+ // without following up with `read`. It maps to content mode; the default
370
+ // context is applied below unless the caller passed an explicit -A/-B/-C/context.
371
+ const wantAutoContext = rawOutputMode === 'content_with_context';
361
372
  // Default to `content` when output_mode is omitted. A pattern is always
362
373
  // present here (the no-pattern case returned above), so this is a content
363
374
  // search — it should return the matching lines WITH line numbers, not just
364
375
  // filenames. Filename-only was forcing callers to re-grep for the actual
365
376
  // coordinates (the explorer over-iteration root cause). `files_with_matches`
366
377
  // is now opt-in; pure filename discovery belongs to `glob`.
367
- const outputMode = rawOutputMode || 'content';
378
+ const outputMode = (rawOutputMode === 'content_with_context') ? 'content' : (rawOutputMode || 'content');
368
379
  const headLimitRaw = args.head_limit;
369
380
  const headLimitCoerced = coerceNonNegInt(headLimitRaw);
370
381
  if (Number.isNaN(headLimitCoerced)) {
@@ -391,6 +402,12 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
391
402
  let contextN = args['-C'] !== undefined && args['-C'] !== null && args['-C'] !== ''
392
403
  ? coerceContext(args['-C'])
393
404
  : coerceContext(args.context);
405
+ // content_with_context: if no explicit context flag was supplied, apply a
406
+ // generous default so the match arrives with enough surrounding code to
407
+ // understand it (function-sized) without a separate read.
408
+ if (wantAutoContext && afterN === null && beforeN === null && contextN === null) {
409
+ contextN = GREP_AUTO_CONTEXT_LINES;
410
+ }
394
411
  if (contextN !== null && contextN > 0) {
395
412
  if (afterN === 0) afterN = null;
396
413
  if (beforeN === 0) beforeN = null;
@@ -462,6 +479,10 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
462
479
  try { grepStat = statSync(grepResolvedPath); }
463
480
  catch (err) {
464
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
+ }
465
486
  return msg + await _suggestIndexedPaths(grepResolvedPath, executeChildBuiltinTool, workDir);
466
487
  }
467
488
  const filenameOmitted = grepStat.isFile();