mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  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/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -161,7 +161,7 @@ function _sessionSummary(session) {
161
161
  closed: session.closed === true,
162
162
  status: String(session.status || (session.closed === true ? 'closed' : 'idle')),
163
163
  owner: session.owner || 'user',
164
- role: session.role || null,
164
+ agent: session.agent || null,
165
165
  sourceType: session.sourceType || null,
166
166
  sourceName: session.sourceName || null,
167
167
  scopeKey: session.scopeKey || null,
@@ -191,7 +191,7 @@ function _normalizeSummaryRow(row) {
191
191
  closed: row.closed === true,
192
192
  status: String(row.status || (row.closed === true ? 'closed' : 'idle')),
193
193
  owner: row.owner || 'user',
194
- role: row.role || null,
194
+ agent: row.agent || null,
195
195
  sourceType: row.sourceType || null,
196
196
  sourceName: row.sourceName || null,
197
197
  scopeKey: row.scopeKey || null,
@@ -655,11 +655,11 @@ export function markSessionClosed(id, reason = 'manual') {
655
655
  const _lifeMs = (typeof existing.createdAt === 'number' && existing.createdAt > 0)
656
656
  ? (tombstone.updatedAt - existing.createdAt)
657
657
  : 0;
658
- const _role = existing.role || '-';
658
+ const _agent = existing.agent || '-';
659
659
  const _owner = existing.owner || '-';
660
660
  void fsp.appendFile(
661
661
  join(_dataDir, 'tool-events.log'),
662
- `[${_ts}] [session-close] owner=${_owner} role=${_role} reason=${reason} lifeMs=${_lifeMs} id=${id}\n`,
662
+ `[${_ts}] [session-close] owner=${_owner} agent=${_agent} reason=${reason} lifeMs=${_lifeMs} id=${id}\n`,
663
663
  ).catch(() => {});
664
664
  }
665
665
  } catch { /* logger never breaks the close path */ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * tool-envelope.mjs — general optional `newMessages` channel for tool results.
3
+ *
4
+ * A tool MAY return either:
5
+ * - legacy: a string (or existing structured media object) — unchanged, OR
6
+ * - an envelope object:
7
+ * { __toolEnvelope: true, result: <string|structured>,
8
+ * newMessages: [{ role:'user', content:'...' }, ...] }
9
+ *
10
+ * The `__toolEnvelope` marker is deliberately namespaced so it can NEVER be
11
+ * confused with the existing structured media content objects that
12
+ * PostToolUse hooks/tests preserve (those use `{ content: [...] }`, never
13
+ * `__toolEnvelope`). `normalizeToolEnvelope` is the single split point: it
14
+ * returns `{ result, newMessages }` so ALL downstream loop code sees only
15
+ * `result` and the loop alone is responsible for flushing `newMessages`.
16
+ */
17
+
18
+ export const TOOL_ENVELOPE_MARKER = '__toolEnvelope';
19
+
20
+ export function isToolEnvelope(value) {
21
+ return !!value
22
+ && typeof value === 'object'
23
+ && !Array.isArray(value)
24
+ && value[TOOL_ENVELOPE_MARKER] === true;
25
+ }
26
+
27
+ function isValidNewMessage(m) {
28
+ return !!m
29
+ && typeof m === 'object'
30
+ && m.role === 'user'
31
+ && typeof m.content === 'string'
32
+ && m.content.length > 0;
33
+ }
34
+
35
+ /**
36
+ * Build a tool-result envelope. `result` is the short stub/data the model
37
+ * sees as the tool_result; `newMessages` are appended (as their own
38
+ * messages, e.g. role:'user') AFTER the batch's tool results.
39
+ */
40
+ export function makeToolEnvelope(result, newMessages = []) {
41
+ return {
42
+ [TOOL_ENVELOPE_MARKER]: true,
43
+ result,
44
+ newMessages: Array.isArray(newMessages) ? newMessages.filter(isValidNewMessage) : [],
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Split a tool return value into `{ result, newMessages }`.
50
+ * - legacy string/object → { result: value, newMessages: [] }
51
+ * - envelope → { result, newMessages } (newMessages validated)
52
+ */
53
+ export function normalizeToolEnvelope(value) {
54
+ if (isToolEnvelope(value)) {
55
+ const newMessages = Array.isArray(value.newMessages)
56
+ ? value.newMessages.filter(isValidNewMessage)
57
+ : [];
58
+ return { result: value.result, newMessages };
59
+ }
60
+ return { result: value, newMessages: [] };
61
+ }
@@ -21,11 +21,16 @@ export const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
21
21
  // READ_MAX_SIZE_BYTES cap, so persisting to a sidecar to be re-read would be
22
22
  // circular. These values keep context-rich IO tools from turning into "read
23
23
  // saved output" loops while bounding the per-call inline footprint per CC.
24
+ // Skill / skill_view bodies stay inline for the same reason — offloading a
25
+ // loaded SKILL.md would force a read loop and defeat the loaded-skill guard.
24
26
  const INLINE_THRESHOLD_BY_TOOL = new Map([
25
27
  ['read', Infinity],
26
28
  ['head', Infinity],
27
29
  ['tail', Infinity],
28
30
  ['diff', Infinity],
31
+ ['skill', Infinity],
32
+ ['skill_view', Infinity],
33
+ ['skills_list', Infinity],
29
34
  ['grep', TOOL_RESULT_GREP_THRESHOLD_CHARS],
30
35
  ['glob', TOOL_RESULT_SEARCH_THRESHOLD_CHARS],
31
36
  ['list', TOOL_RESULT_SEARCH_THRESHOLD_CHARS],
@@ -1,4 +1,4 @@
1
- import { getHiddenRole } from './internal-roles.mjs';
1
+ import { getHiddenAgent } from './internal-agents.mjs';
2
2
 
3
3
  const SECOND_MS = 1000;
4
4
  const MIN_PROVIDER_TIMEOUT_MS = 30_000;
@@ -68,7 +68,13 @@ export const DEFAULT_ACTIVITY_HEARTBEAT_MS = resolveTimeoutMs(
68
68
 
69
69
  export const PROVIDER_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
70
70
  'MIXDOG_PROVIDER_FIRST_BYTE_TIMEOUT_MS',
71
- Math.min(120_000, PROVIDER_MAX_BEFORE_WARN_MS),
71
+ // 2026-07 trace audit: heavy-worker claude-sonnet-5 xhigh TTFT distribution
72
+ // showed avg ~25s, max ~120s (15/75 requests over 60s) — the old 120s
73
+ // window sat exactly on the observed tail and killed/retried healthy slow
74
+ // first bytes (9 first-byte retries in one 75-iteration session, plus one
75
+ // full turn lost). 180s clears the tail while the mid-stream retry ladder
76
+ // and the agent stall watchdog still bound a truly dead socket.
77
+ Math.min(180_000, PROVIDER_MAX_BEFORE_WARN_MS),
72
78
  { minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
73
79
  );
74
80
 
@@ -125,6 +131,44 @@ export const PROVIDER_SSE_IDLE_TIMEOUT_MS = resolveTimeoutMs(
125
131
  { minMs: 10_000, maxMs: STALL_WARN_MS },
126
132
  );
127
133
 
134
+ // Semantic (last-meaningful-event) idle window. Distinct name from the
135
+ // transport-byte SSE idle so provider loops can key their mid-stream abort off
136
+ // SEMANTIC progress (message/content/tool deltas) rather than raw keepalive
137
+ // bytes (Anthropic `:ping`, comment frames). A truly silent stream — one that
138
+ // emits no semantic event for this window — trips it; a live extended-thinking
139
+ // stream (which emits thinking deltas) stays alive. Default 120s, floor 10s,
140
+ // env-overridable and disablable via MIXDOG_ENABLE_STREAM_WATCHDOG=0.
141
+ export const PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS = resolveTimeoutMs(
142
+ ['MIXDOG_PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS', 'MIXDOG_PROVIDER_SSE_IDLE_TIMEOUT_MS'],
143
+ // Raised 120s→180s alongside PROVIDER_FIRST_BYTE_TIMEOUT_MS (see comment
144
+ // there): observed healthy inter-event gaps on xhigh reasoning streams
145
+ // brushed the old 120s window; stalls after a tool emit are unretryable,
146
+ // so a false trip there costs the whole turn.
147
+ 180_000,
148
+ { minMs: 10_000, maxMs: STALL_WARN_MS },
149
+ );
150
+
151
+ // Named terminal error for a mid-stream SEMANTIC idle abort. Distinct from a
152
+ // user cancel (signal.aborted): the retry-classifier treats this as a terminal
153
+ // STREAM FAILURE so the owner (Lead) receives a failure notification instead of
154
+ // the task hanging. The message keeps "timed out after …ms of inactivity" so
155
+ // the SSE mid-stream classifier's text match (sse_idle_timeout) still fires,
156
+ // and code 'ESTREAMSTALL' routes it through the transient/notify path.
157
+ export function streamStalledError(label, timeoutMs, { emittedToolCall = false } = {}) {
158
+ const err = new Error(`${label} stream timed out after ${timeoutMs}ms of inactivity`);
159
+ err.name = 'StreamStalledError';
160
+ err.code = 'ESTREAMSTALL';
161
+ err.streamStalled = true;
162
+ // Double-dispatch guard (reviewer High): once a tool call has already been
163
+ // emitted this turn (including a recovered text-leaked call), retrying the
164
+ // request would RE-RUN that side-effecting tool. Mark such a stall
165
+ // unsafe-to-retry so withRetry() throws it straight through to the terminal
166
+ // notify path instead of replaying the turn. A stall BEFORE any tool emit is
167
+ // still safely retryable (nothing has executed yet).
168
+ if (emittedToolCall) err.unsafeToRetry = true;
169
+ return err;
170
+ }
171
+
128
172
  export const PROVIDER_WS_HANDSHAKE_TIMEOUT_MS = resolveTimeoutMs(
129
173
  'MIXDOG_PROVIDER_WS_HANDSHAKE_TIMEOUT_MS',
130
174
  30_000,
@@ -137,19 +181,23 @@ export const PROVIDER_WS_ACQUIRE_TIMEOUT_MS = resolveTimeoutMs(
137
181
  { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
138
182
  );
139
183
 
140
- export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
141
- 'MIXDOG_PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS',
142
- Math.min(PROVIDER_FIRST_BYTE_TIMEOUT_MS, PROVIDER_GENERATE_TOTAL_TIMEOUT_MS),
143
- { minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
144
- );
145
-
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
187
+ // is deliberately no separate "first-meaningful" watchdog — a live stream
188
+ // (including server-side reasoning ACKed via response.created) keeps this timer
189
+ // fresh, and only true socket silence trips it. Env-tunable.
146
190
  export const PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS = resolveTimeoutMs(
147
191
  'MIXDOG_PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS',
148
- STALL_ABORT_MS,
149
- { minMs: STALL_WARN_MS, maxMs: STALL_ABORT_MS },
192
+ 300_000,
193
+ { minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: STALL_ABORT_MS },
150
194
  );
151
195
 
152
- export const PROVIDER_RETRY_BACKOFF_MS = Object.freeze([0, 1000, 2000, 4000, 8000]);
196
+ // First retry has a small floor (250ms) instead of 0ms: an immediate reissue on
197
+ // a transient 5xx burst lets parallel workers thundering-herd the backend in
198
+ // lockstep (jitter alone can't decluster a 0ms base). Subsequent steps keep the
199
+ // exponential schedule. Env-tunable via the providers' retryJitterRatio.
200
+ export const PROVIDER_RETRY_BACKOFF_MS = Object.freeze([250, 1000, 2000, 4000, 8000]);
153
201
  export const PROVIDER_RETRY_MAX_ATTEMPTS = PROVIDER_RETRY_BACKOFF_MS.length;
154
202
  export const PROVIDER_RETRY_JITTER_RATIO = (() => {
155
203
  const raw = process.env.MIXDOG_PROVIDER_RETRY_JITTER_RATIO;
@@ -158,8 +206,8 @@ export const PROVIDER_RETRY_JITTER_RATIO = (() => {
158
206
  return 0.2;
159
207
  })();
160
208
 
161
- export function resolveAgentStallThresholds(role, env = process.env) {
162
- const cfg = role ? getHiddenRole(role) : null;
209
+ export function resolveAgentStallThresholds(agent, env = process.env) {
210
+ const cfg = agent ? getHiddenAgent(agent) : null;
163
211
  const cfgAbort = cfg?.stallCap?.idleSeconds > 0 ? cfg.stallCap.idleSeconds : STALL_ABORT_S;
164
212
  const envOverride = envThresholdSeconds(env);
165
213
  const abort = envOverride != null ? envOverride : cfgAbort;
@@ -175,8 +223,8 @@ export function resolveAgentStallThresholds(role, env = process.env) {
175
223
  return { warn, abort, firstByteAbort };
176
224
  }
177
225
 
178
- export function resolveAgentToolThresholdSeconds(role, thresholdSeconds) {
179
- const cfg = role ? getHiddenRole(role) : null;
226
+ export function resolveAgentToolThresholdSeconds(agent, thresholdSeconds) {
227
+ const cfg = agent ? getHiddenAgent(agent) : null;
180
228
  if (cfg?.stallCap?.toolRunningSeconds > 0) return cfg.stallCap.toolRunningSeconds;
181
229
  return thresholdSeconds;
182
230
  }
@@ -44,7 +44,7 @@ import { invalidateBuiltinResultCache, analyzeShellCommandEffects, preflightShel
44
44
  import { markCodeGraphDirtyPaths, drainCodeGraphCache } from './code-graph-state.mjs';
45
45
  import { maybeRewriteWmicProcessCommand } from './shell-policy.mjs';
46
46
  import { _maybeEncodePowerShellCommand } from './shell-command.mjs';
47
- import { _captureTrackedMtimes, _trackedDriftNoteAfter, _injectionBlockTargets, getDedupedDestructiveWarnings } from './builtin/bash-tool.mjs';
47
+ import { _captureTrackedMtimes, _trackedDriftNoteAfter, getDedupedDestructiveWarnings } from './builtin/bash-tool.mjs';
48
48
  import { scrubLoaderVars, scrubProviderSecrets } from './env-scrub.mjs';
49
49
  import { checkExecPolicyMessage } from './bash-policy-scan.mjs';
50
50
  import { startChildGuardian } from '../../../shared/child-guardian.mjs';
@@ -9,13 +9,22 @@
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
- const MAX_INT = 100000;
13
- export const GREP_CONTEXT_MAX = 12;
12
+ import { coerceShapeFlex, hasGlobMagic } from './path-utils.mjs';
14
13
 
14
+ const MAX_INT = 100000;
15
+ // Explicit grep context should be large enough to frame a function/block without
16
+ // letting one match explode into a huge tool result. `content_with_context` still
17
+ // defaults to 25 lines; this is only the upper bound for caller-supplied -A/-B/-C.
18
+ export const GREP_CONTEXT_MAX = 200;
19
+
20
+ // ripgrep-flavored aliases: models trained on `rg` emit short flags (-A/-B/-C),
21
+ // long flags (--after-context / --before-context / --context), or snake/camel
22
+ // spellings. All fold onto the canonical -A/-B/-C so a caller can write grep
23
+ // the way they would write ripgrep on the shell.
15
24
  const GREP_CONTEXT_KEY_GROUPS = [
16
- ['-A', ['-A', 'A', 'after', 'after_context', 'afterContext']],
17
- ['-B', ['-B', 'B', 'before', 'before_context', 'beforeContext']],
18
- ['-C', ['-C', 'C', 'context', 'context_lines', 'contextLines']],
25
+ ['-A', ['-A', 'A', 'after', 'after_context', 'afterContext', '--after-context', 'after-context', 'afterLines', 'after_lines']],
26
+ ['-B', ['-B', 'B', 'before', 'before_context', 'beforeContext', '--before-context', 'before-context', 'beforeLines', 'before_lines']],
27
+ ['-C', ['-C', 'C', 'context', 'context_lines', 'contextLines', '--context', 'contextN', 'around', 'surrounding']],
19
28
  ];
20
29
 
21
30
  function grepContextKeyPresent(a, k) {
@@ -76,11 +85,6 @@ function isStringOrStringArray(v) {
76
85
  return true;
77
86
  }
78
87
 
79
- function hasBackslashPipe(value) {
80
- if (typeof value === 'string') return false;
81
- return false;
82
- }
83
-
84
88
  function hasUnsupportedRipgrepRegex(value) {
85
89
  const values = Array.isArray(value) ? value : [value];
86
90
  return values.some((item) => {
@@ -103,8 +107,35 @@ function isFiniteInt(v) {
103
107
  return typeof v === 'number' && Number.isFinite(v) && Math.floor(v) === v;
104
108
  }
105
109
 
106
- function checkIntInRange(field, value, min, max) {
110
+ // Lossless numeric-string coercion for integer-shaped args. Models
111
+ // occasionally emit JSON-schema-violating numeric strings ("850",
112
+ // "850.0") for fields the schema types as number (offset/limit/n/
113
+ // head_limit/-A/-B/-C/context/line). Both are unambiguous integer
114
+ // values, so coerce them to a real number before validating rather
115
+ // than rejecting and forcing a retry turn. Non-integer or non-numeric
116
+ // strings ("3.5", "soon") are left untouched and fall through to the
117
+ // existing rejection below.
118
+ function coerceIntegerString(v) {
119
+ if (typeof v !== 'string') return null;
120
+ const t = v.trim();
121
+ if (t === '' || !/^-?\d+(\.\d+)?$/.test(t)) return null;
122
+ const n = Number(t);
123
+ if (!Number.isFinite(n) || !Number.isInteger(n)) return null;
124
+ return n;
125
+ }
126
+
127
+ // Mutates a[field] in place when it is a lossless integer string, then
128
+ // validates the (possibly coerced) value against [min, max].
129
+ function checkIntInRange(a, field, min, max) {
130
+ let value = a[field];
107
131
  if (value === undefined || value === null) return null;
132
+ if (typeof value === 'string') {
133
+ const coerced = coerceIntegerString(value);
134
+ if (coerced !== null) {
135
+ value = coerced;
136
+ a[field] = coerced;
137
+ }
138
+ }
108
139
  if (!isFiniteInt(value)) {
109
140
  return `Error: builtin arg "${field}" must be a finite integer (got ${describeType(value)})`;
110
141
  }
@@ -127,6 +158,38 @@ function hasOwn(o, k) {
127
158
  return o && Object.prototype.hasOwnProperty.call(o, k);
128
159
  }
129
160
 
161
+ function isPresent(o, k) {
162
+ return hasOwn(o, k) && o[k] !== undefined && o[k] !== null;
163
+ }
164
+
165
+ function isNonEmptyPresent(o, k) {
166
+ return isPresent(o, k) && o[k] !== '';
167
+ }
168
+
169
+ // Strip trailing literal artifacts from a grep pattern: a literal two-char
170
+ // "\n" (backslash + n) that ripgrep rejects outside multiline mode ("the
171
+ // literal \"\\n\" is not allowed"), possibly preceded by concatenation
172
+ // debris like `">` that rides along with it (e.g. a stray closing-tag
173
+ // fragment glued on by string interpolation). `">` is ONLY stripped when
174
+ // it is directly followed by one of those newline artifacts — a bare
175
+ // trailing `">` with no newline riding along (e.g. a legit HTML/JSX
176
+ // attribute pattern like `class="active">`) is a real search target and
177
+ // must survive untouched. A \n in the middle of a pattern is also left
178
+ // untouched; only the tail is ever trimmed.
179
+ function stripTrailingPatternArtifacts(v) {
180
+ if (typeof v !== 'string') return v;
181
+ let out = v;
182
+ let changed = true;
183
+ while (changed) {
184
+ changed = false;
185
+ if (out.endsWith('">\n')) { out = out.slice(0, -3); changed = true; continue; }
186
+ if (out.endsWith('">\\n')) { out = out.slice(0, -4); changed = true; continue; }
187
+ if (out.endsWith('\n')) { out = out.slice(0, -1); changed = true; continue; }
188
+ if (out.endsWith('\\n')) { out = out.slice(0, -2); changed = true; continue; }
189
+ }
190
+ return out;
191
+ }
192
+
130
193
  // ---- per-tool guards ----
131
194
 
132
195
  function guardGrep(a) {
@@ -135,6 +198,15 @@ function guardGrep(a) {
135
198
  // glob (file filter) aliases
136
199
  const globKeys = ['glob', 'file_pattern', 'include', 'files'];
137
200
 
201
+ // Lossless cleanup of trailing artifacts before validation (item 5b).
202
+ for (const k of patternKeys) {
203
+ if (hasOwn(a, k)) {
204
+ a[k] = Array.isArray(a[k])
205
+ ? a[k].map(stripTrailingPatternArtifacts)
206
+ : stripTrailingPatternArtifacts(a[k]);
207
+ }
208
+ }
209
+
138
210
  const hasPattern = patternKeys.some((k) => hasOwn(a, k));
139
211
  const hasGlob = globKeys.some((k) => hasOwn(a, k));
140
212
  if (!hasPattern && !hasGlob) {
@@ -144,9 +216,6 @@ function guardGrep(a) {
144
216
  if (hasOwn(a, k) && !isStringOrStringArray(a[k])) {
145
217
  return `Error: grep arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
146
218
  }
147
- if (hasOwn(a, k) && hasBackslashPipe(a[k])) {
148
- return `Error: grep arg "${k}" contains \\|. Use pattern:["a","b"] for OR terms, or unescaped | for regex alternation.`;
149
- }
150
219
  if (hasOwn(a, k) && hasUnsupportedRipgrepRegex(a[k])) {
151
220
  return `Error: grep arg "${k}" uses regex syntax ripgrep does not support here (lookaround/backrefs). Use plain pattern arrays or simpler regex.`;
152
221
  }
@@ -165,34 +234,93 @@ function guardGrep(a) {
165
234
  return `Error: grep arg "${k}" contains multiple absolute paths in one string. Use one common parent path plus glob, or separate grep calls.`;
166
235
  }
167
236
  }
168
- // numeric clamps (grep context 0..12, mirrors read line-context tightening)
169
237
  for (const k of ['head_limit', 'offset']) {
170
- const err = checkIntInRange(k, a[k], 0, MAX_INT);
238
+ const err = checkIntInRange(a, k, 0, MAX_INT);
171
239
  if (err) return err;
172
240
  }
173
241
  for (const k of ['-A', '-B', '-C', 'context']) {
174
- const err = checkIntInRange(k, a[k], 0, GREP_CONTEXT_MAX);
242
+ const err = checkIntInRange(a, k, 0, GREP_CONTEXT_MAX);
175
243
  if (err) return err;
176
244
  }
177
245
  // output_mode / mode enum
178
246
  const modeKeys = ['output_mode', 'mode'];
179
- const allowed = new Set(['files_with_matches', 'content', 'count']);
247
+ const allowed = new Set(['files_with_matches', 'content', 'content_with_context', 'count']);
180
248
  for (const k of modeKeys) {
181
249
  if (hasOwn(a, k)) {
250
+ // Some callers concatenate a second field's value onto the enum
251
+ // string with a literal newline (e.g. "content_with_context\ntrue").
252
+ // If the first line/token is a valid enum value, truncate to it
253
+ // losslessly rather than rejecting a shape that unambiguously
254
+ // names a real mode (item 5a).
255
+ if (typeof a[k] === 'string' && a[k].includes('\n')) {
256
+ const firstLine = a[k].split('\n')[0].trim();
257
+ const firstToken = firstLine.split(/\s+/)[0];
258
+ if (allowed.has(firstToken)) a[k] = firstToken;
259
+ }
182
260
  if (!isString(a[k]) || !allowed.has(a[k])) {
183
- return `Error: grep arg "${k}" must be one of files_with_matches|content|count (got ${JSON.stringify(a[k])})`;
261
+ return `Error: grep arg "${k}" must be one of content_with_context|content|files_with_matches|count (got ${JSON.stringify(a[k])})`;
184
262
  }
185
263
  }
186
264
  }
187
265
  return null;
188
266
  }
189
267
 
268
+ // Convert a {line, context} pair into {offset, limit}, matching the
269
+ // read-args.mjs private file#Lx compatibility normalizer's semantics
270
+ // (offset = startLine - 1, limit = endLine - startLine + 1, clamped at 0)
271
+ // generalized to a symmetric window: startLine = line - context,
272
+ // endLine = line + context. offset/limit are treated as authoritative —
273
+ // if either is already present, line/context are dropped unused rather
274
+ // than overriding an explicit window. Mutates obj in place. Returns an
275
+ // error string or null.
276
+ function applyLineContextWindow(obj, labelPrefix) {
277
+ for (const k of ['line', 'context', 'offset', 'limit']) {
278
+ if (isPresent(obj, k) && typeof obj[k] === 'string') {
279
+ const coerced = coerceIntegerString(obj[k]);
280
+ if (coerced !== null) obj[k] = coerced;
281
+ }
282
+ }
283
+ const hasLine = isPresent(obj, 'line');
284
+ const hasContext = isPresent(obj, 'context');
285
+ if (!hasLine && !hasContext) return null;
286
+ if (isPresent(obj, 'offset') || isPresent(obj, 'limit')) {
287
+ delete obj.line;
288
+ delete obj.context;
289
+ return null;
290
+ }
291
+ if (!hasLine) {
292
+ return `Error: read arg "${labelPrefix}context" requires "${labelPrefix}line" to compute a window`;
293
+ }
294
+ if (!isFiniteInt(obj.line) || obj.line < 1) {
295
+ return `Error: read arg "${labelPrefix}line" must be a finite integer >= 1 (got ${describeType(obj.line)})`;
296
+ }
297
+ let ctx = 0;
298
+ if (hasContext) {
299
+ if (!isFiniteInt(obj.context) || obj.context < 0) {
300
+ return `Error: read arg "${labelPrefix}context" must be a non-negative finite integer (got ${describeType(obj.context)})`;
301
+ }
302
+ ctx = obj.context;
303
+ }
304
+ obj.offset = Math.max(0, obj.line - 1 - ctx);
305
+ obj.limit = 2 * ctx + 1;
306
+ delete obj.line;
307
+ delete obj.context;
308
+ return null;
309
+ }
310
+
190
311
  function guardRead(a) {
191
312
  // path / file_path alias OR path may itself be array
192
313
  const hasPath = hasOwn(a, 'path') || hasOwn(a, 'file_path');
193
314
  if (!hasPath) {
194
315
  return 'Error: read requires "path" (or alias file_path).';
195
316
  }
317
+ // Some providers/models send a batched path array as a JSON string despite
318
+ // the schema. The executor already accepts that shape via coerceShapeFlex(),
319
+ // but validation runs first; apply the same lossless shape coercion here so
320
+ // valid batched reads do not waste a retry turn.
321
+ if (hasOwn(a, 'path')) {
322
+ a.path = coerceShapeFlex(a.path);
323
+ }
196
324
  // path can be string | string[] | object[]; file_path is string
197
325
  if (hasOwn(a, 'path')) {
198
326
  const p = a.path;
@@ -200,16 +328,36 @@ function guardRead(a) {
200
328
  if (!ok) {
201
329
  return `Error: read arg "path" must be string, string[], or object[] (got ${describeType(p)})`;
202
330
  }
331
+ if (Array.isArray(p)) {
332
+ for (let i = 0; i < p.length; i++) {
333
+ const entry = p[i];
334
+ if (typeof entry === 'string') continue;
335
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
336
+ return `Error: read arg "path[${i}]" must be string or {path,offset,limit} object (got ${describeType(entry)})`;
337
+ }
338
+ const err = applyLineContextWindow(entry, `path[${i}].`);
339
+ if (err) return err;
340
+ for (const ek of ['offset', 'limit']) {
341
+ const eErr = checkIntInRange(entry, ek, 0, MAX_INT);
342
+ if (eErr) return eErr.replace(`"${ek}"`, `"path[${i}].${ek}"`);
343
+ }
344
+ }
345
+ }
203
346
  }
204
347
  if (hasOwn(a, 'file_path') && !isNonEmptyString(a.file_path)) {
205
348
  return `Error: read arg "file_path" must be a non-empty string (got ${describeType(a.file_path)})`;
206
349
  }
207
- if (hasOwn(a, 'line') && (hasOwn(a, 'offset') || hasOwn(a, 'limit'))) {
208
- return 'Error: read window args conflict use exactly one window family: line+context OR offset+limit OR symbol.';
350
+ // Read's public surface is offset/limit, but a top-level line/context pair
351
+ // is a deterministic, lossless spelling of the same window (matching
352
+ // read-args.mjs's internal file:line normalizer semantics); convert it
353
+ // instead of rejecting. offset/limit, if already present, win outright.
354
+ {
355
+ const err = applyLineContextWindow(a, '');
356
+ if (err) return err;
209
357
  }
210
358
  // offset >=0
211
359
  {
212
- const err = checkIntInRange('offset', a.offset, 0, MAX_INT);
360
+ const err = checkIntInRange(a, 'offset', 0, MAX_INT);
213
361
  if (err) return err;
214
362
  }
215
363
  // limit: >=1 = explicit cap; 0 = unlimited sentinel (read-formatting maps 0 to
@@ -218,12 +366,7 @@ function guardRead(a) {
218
366
  // break that unbounded-batch contract. A placeholder limit:0 from a symbol
219
367
  // read is stripped on the symbol path. Negatives still error.
220
368
  {
221
- const err = checkIntInRange('limit', a.limit, 0, MAX_INT);
222
- if (err) return err;
223
- }
224
- // context 0..200
225
- {
226
- const err = checkIntInRange('context', a.context, 0, 200);
369
+ const err = checkIntInRange(a, 'limit', 0, MAX_INT);
227
370
  if (err) return err;
228
371
  }
229
372
  // n 0..10000 — accept 0 rather than erroring: the read-mode handlers coerce
@@ -232,7 +375,7 @@ function guardRead(a) {
232
375
  // n is moot. Rejecting 0 only forced a wasted retry turn (the whole point of
233
376
  // these reads is to land in one shot). Negatives remain a real error.
234
377
  if (hasOwn(a, 'n') && a.n !== undefined && a.n !== null) {
235
- const err = checkIntInRange('n', a.n, 0, 10000);
378
+ const err = checkIntInRange(a, 'n', 0, 10000);
236
379
  if (err) return err;
237
380
  }
238
381
  return null;
@@ -256,7 +399,10 @@ function guardShell(a) {
256
399
  return 'Error: shell arg "command" must be a non-empty string';
257
400
  }
258
401
  if (process.platform === 'win32' && !hasOwn(a, 'shell') && hasWindowsDrivePath(a.command)) {
259
- return "Error: shell command contains a Windows drive path; set shell:'powershell' or convert paths for shell:'bash'.";
402
+ // A Windows drive path (C:\...) is unambiguous evidence the caller
403
+ // wants the Windows shell; default it losslessly instead of forcing
404
+ // a retry turn just to add shell:'powershell'.
405
+ a.shell = 'powershell';
260
406
  }
261
407
  for (const k of ['cwd', 'workdir']) {
262
408
  if (hasOwn(a, k) && (a[k] === undefined || a[k] === null || a[k] === '')) {
@@ -331,12 +477,28 @@ function guardFind(a) {
331
477
 
332
478
  function guardGlob(a) {
333
479
  // path alias root; pattern aliases glob/name/file_pattern
480
+ const globPatternKeys = ['pattern', 'glob', 'name', 'file_pattern'];
481
+ const hasAnyPattern = globPatternKeys.some((k) => isNonEmptyPresent(a, k));
482
+ // Skip the default when `path` itself carries glob magic (*?[{) — that
483
+ // shape means "path IS the pattern" and is handled by executeGlobTool's
484
+ // own path-magic fallback (splitting path into baseDir + pattern).
485
+ // Injecting pattern:'*' here would override that fallback and silently
486
+ // change "src/**/*.mjs" into "match everything under src/**/*.mjs".
487
+ const pathHasGlobMagic = Array.isArray(a.path)
488
+ ? a.path.some((p) => hasGlobMagic(p))
489
+ : hasGlobMagic(a.path);
490
+ if (!hasAnyPattern && isNonEmptyPresent(a, 'path') && !pathHasGlobMagic) {
491
+ // Missing pattern with a real path is an unambiguous "match
492
+ // everything under this path" request; default it instead of
493
+ // erroring out via globMissingPatternMessage() downstream.
494
+ a.pattern = '*';
495
+ }
334
496
  for (const k of ['path', 'root']) {
335
497
  if (hasOwn(a, k) && !isStringOrStringArray(a[k])) {
336
498
  return `Error: glob arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
337
499
  }
338
500
  }
339
- for (const k of ['pattern', 'glob', 'name', 'file_pattern']) {
501
+ for (const k of globPatternKeys) {
340
502
  if (hasOwn(a, k) && !isStringOrStringArray(a[k])) {
341
503
  return `Error: glob arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
342
504
  }
@@ -359,7 +521,7 @@ function guardGlob(a) {
359
521
  // find_symbol, references, callers, imports, dependents.
360
522
  const CODE_GRAPH_MODES = new Set([
361
523
  'overview', 'imports', 'dependents', 'related', 'impact',
362
- 'symbols', 'find_symbol', 'search', 'references', 'callers', 'callees', 'prewarm',
524
+ 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees', 'prewarm',
363
525
  ]);
364
526
 
365
527
  function guardCodeGraph(a) {