mixdog 0.9.18 → 0.9.20

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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -29
  3. package/package.json +5 -3
  4. package/scripts/build-runtime-windows.ps1 +242 -242
  5. package/scripts/build-tui.mjs +6 -0
  6. package/scripts/hook-bus-test.mjs +8 -0
  7. package/scripts/log-writer-guard-smoke.mjs +131 -0
  8. package/scripts/output-style-smoke.mjs +2 -2
  9. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  10. package/scripts/recall-bench-cases.json +10 -0
  11. package/scripts/recall-bench.mjs +91 -2
  12. package/scripts/recall-quality-cases.json +1 -2
  13. package/scripts/recall-usecase-cases.json +1 -1
  14. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  15. package/scripts/smoke-runtime-negative.ps1 +106 -106
  16. package/scripts/tool-efficiency-diag.mjs +5 -2
  17. package/scripts/tool-smoke.mjs +251 -72
  18. package/src/agents/debugger/AGENT.md +3 -3
  19. package/src/agents/heavy-worker/AGENT.md +7 -10
  20. package/src/agents/maintainer/AGENT.md +1 -2
  21. package/src/agents/reviewer/AGENT.md +1 -2
  22. package/src/agents/worker/AGENT.md +5 -8
  23. package/src/defaults/agents.json +3 -0
  24. package/src/help.mjs +2 -5
  25. package/src/lib/mixdog-debug.cjs +13 -0
  26. package/src/mixdog-session-runtime.mjs +7 -3311
  27. package/src/rules/agent/00-core.md +4 -3
  28. package/src/rules/agent/30-explorer.md +53 -22
  29. package/src/rules/lead/02-channels.md +3 -3
  30. package/src/rules/lead/lead-tool.md +3 -2
  31. package/src/rules/shared/01-tool.md +24 -29
  32. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  39. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  42. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  43. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  45. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  48. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  50. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  51. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  52. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  53. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  54. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  55. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  57. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  58. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  59. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  60. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  61. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  62. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  63. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  66. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  68. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  74. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  80. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  83. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  84. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  85. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  86. package/src/runtime/channels/backends/discord.mjs +43 -1
  87. package/src/runtime/channels/index.mjs +6 -2096
  88. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  89. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  90. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  91. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  92. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  93. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  94. package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
  95. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  96. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  97. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  98. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  99. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  100. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  101. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  102. package/src/runtime/channels/lib/worker-main.mjs +771 -0
  103. package/src/runtime/memory/index.mjs +73 -1725
  104. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  105. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  106. package/src/runtime/memory/lib/http-router.mjs +772 -0
  107. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  108. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  109. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  110. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  111. package/src/runtime/memory/lib/query-handlers.mjs +38 -6
  112. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  113. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  114. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  115. package/src/runtime/shared/atomic-file.mjs +10 -4
  116. package/src/runtime/shared/background-tasks.mjs +4 -2
  117. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  118. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  119. package/src/runtime/shared/tool-surface.mjs +30 -1
  120. package/src/session-runtime/boot-profile.mjs +36 -0
  121. package/src/session-runtime/channel-config-api.mjs +70 -0
  122. package/src/session-runtime/config-lifecycle.mjs +1 -1
  123. package/src/session-runtime/context-status.mjs +181 -0
  124. package/src/session-runtime/cwd-plugins.mjs +46 -3
  125. package/src/session-runtime/env.mjs +17 -0
  126. package/src/session-runtime/lifecycle-api.mjs +242 -0
  127. package/src/session-runtime/mcp-glue.mjs +24 -3
  128. package/src/session-runtime/model-route-api.mjs +198 -0
  129. package/src/session-runtime/output-styles.mjs +44 -10
  130. package/src/session-runtime/provider-auth-api.mjs +135 -0
  131. package/src/session-runtime/resource-api.mjs +282 -0
  132. package/src/session-runtime/runtime-core.mjs +2046 -0
  133. package/src/session-runtime/session-turn-api.mjs +274 -0
  134. package/src/session-runtime/tool-catalog.mjs +18 -264
  135. package/src/session-runtime/tool-defs.mjs +2 -2
  136. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  137. package/src/session-runtime/workflow.mjs +16 -1
  138. package/src/standalone/agent-tool.mjs +2 -2
  139. package/src/standalone/channel-worker.mjs +78 -5
  140. package/src/standalone/explore-tool.mjs +1 -1
  141. package/src/standalone/memory-runtime-proxy.mjs +56 -6
  142. package/src/tui/App.jsx +88 -97
  143. package/src/tui/app/channel-pickers.mjs +45 -0
  144. package/src/tui/app/core-memory-picker.mjs +1 -1
  145. package/src/tui/app/slash-commands.mjs +0 -1
  146. package/src/tui/app/slash-dispatch.mjs +0 -16
  147. package/src/tui/app/transcript-window.mjs +44 -1
  148. package/src/tui/app/use-mouse-input.mjs +15 -2
  149. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  150. package/src/tui/app/use-transcript-scroll.mjs +82 -4
  151. package/src/tui/app/use-transcript-window.mjs +65 -5
  152. package/src/tui/components/PromptInput.jsx +33 -64
  153. package/src/tui/components/ToolExecution.jsx +2 -2
  154. package/src/tui/dist/index.mjs +7908 -7558
  155. package/src/tui/engine/context-state.mjs +145 -0
  156. package/src/tui/engine/prompt-history.mjs +27 -0
  157. package/src/tui/engine/session-api-ext.mjs +478 -0
  158. package/src/tui/engine/session-api.mjs +545 -0
  159. package/src/tui/engine/session-flow.mjs +485 -0
  160. package/src/tui/engine/turn.mjs +1078 -0
  161. package/src/tui/engine.mjs +69 -2582
  162. package/src/tui/index.jsx +7 -0
  163. package/src/tui/lib/voice-setup.mjs +166 -0
  164. package/src/tui/paste-attachments.mjs +12 -5
  165. package/src/tui/prompt-history-store.mjs +125 -12
  166. package/vendor/ink/build/ink.js +16 -1
  167. package/vendor/ink/build/output.js +30 -4
  168. package/vendor/ink/build/render.js +5 -0
  169. package/scripts/bench/cache-probe-tasks.json +0 -8
  170. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  171. package/scripts/bench/lead-review-tasks.json +0 -20
  172. package/scripts/bench/r4-mixed-tasks.json +0 -20
  173. package/scripts/bench/r5-orchestrated-task.json +0 -7
  174. package/scripts/bench/review-tasks.json +0 -20
  175. package/scripts/bench/round-codex.json +0 -114
  176. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  177. package/scripts/bench/round-mixdog-lead.json +0 -269
  178. package/scripts/bench/round-mixdog.json +0 -126
  179. package/scripts/bench/round-r10-bigsample.json +0 -679
  180. package/scripts/bench/round-r11-codexalign.json +0 -257
  181. package/scripts/bench/round-r13-clientmeta.json +0 -464
  182. package/scripts/bench/round-r14-betafeatures.json +0 -466
  183. package/scripts/bench/round-r15-fulldefault.json +0 -462
  184. package/scripts/bench/round-r16-sessionid.json +0 -466
  185. package/scripts/bench/round-r17-wirebytes.json +0 -456
  186. package/scripts/bench/round-r18-prewarm.json +0 -468
  187. package/scripts/bench/round-r19-clean.json +0 -472
  188. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  189. package/scripts/bench/round-r21-delta-retry.json +0 -473
  190. package/scripts/bench/round-r22-full-probe.json +0 -693
  191. package/scripts/bench/round-r23-itemprobe.json +0 -701
  192. package/scripts/bench/round-r24-shapefix.json +0 -677
  193. package/scripts/bench/round-r25-serial.json +0 -464
  194. package/scripts/bench/round-r26-parallel3.json +0 -671
  195. package/scripts/bench/round-r27-parallel10.json +0 -894
  196. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  197. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  198. package/scripts/bench/round-r30-instid.json +0 -253
  199. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  200. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  201. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  202. package/scripts/bench/round-r34-orchestrated.json +0 -120
  203. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  204. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  205. package/scripts/bench/round-r4-codex.json +0 -114
  206. package/scripts/bench/round-r4-mixed.json +0 -225
  207. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  208. package/scripts/bench/round-r6-codex.json +0 -114
  209. package/scripts/bench/round-r6-solo.json +0 -257
  210. package/scripts/bench/round-r7-full.json +0 -254
  211. package/scripts/bench/round-r8-fulldefault.json +0 -255
  212. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  213. package/src/tui/lib/voice-recorder.mjs +0 -469
  214. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -53,6 +53,7 @@ import {
53
53
  normalizeGrepLine,
54
54
  splitGrepCountPrefix,
55
55
  splitGrepLinePrefix,
56
+ splitGrepLineNumberOnlyPrefix,
56
57
  } from './grep-formatting.mjs';
57
58
  import {
58
59
  cacheGet,
@@ -66,6 +67,8 @@ import { applyGrepContextLeadPolicy, GREP_CONTEXT_MAX, hasUnsupportedRipgrepRege
66
67
  // Default surrounding-lines window applied by output_mode:'content_with_context'
67
68
  // when the caller does not pass an explicit -A/-B/-C/context. Sized to cover a
68
69
  // typical function/block so a match arrives readable without a follow-up read.
70
+ // Explicit -A/-B/-C is honored up to the generic GREP_CONTEXT_MAX (no tighter
71
+ // context-mode clamp by policy); only head_limit blocks are context-clamped.
69
72
  const GREP_AUTO_CONTEXT_LINES = 25;
70
73
 
71
74
  const MIXDOG_GREP_CASE_HINT_PROBE = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_GREP_CASE_HINT_PROBE || ''));
@@ -361,12 +364,116 @@ function buildGrepChunkMergePrefix(patternChunkCount, truncated, aggregateBudget
361
364
  return `[${parts.join('; ')}]\n`;
362
365
  }
363
366
 
367
+ // --- context-mode match-block windowing (Parts 2 & 3) ---------------------
368
+ // In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
369
+ // and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
370
+ // head+tail slice with a middle marker instead of dropping the tail.
371
+ function grepBlockMatchAnchor(line, filenameOmitted) {
372
+ if (filenameOmitted) {
373
+ const p = splitGrepLineNumberOnlyPrefix(line);
374
+ return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
375
+ }
376
+ const s = splitGrepLinePrefix(line);
377
+ return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
378
+ }
379
+
380
+ function parseGrepContextBlocks(lines, filenameOmitted) {
381
+ const blocks = [];
382
+ let pending = [];
383
+ let i = 0;
384
+ while (i < lines.length) {
385
+ const line = lines[i];
386
+ if (line === '--') { pending = []; i++; continue; }
387
+ const anchor = grepBlockMatchAnchor(line, filenameOmitted);
388
+ if (anchor) {
389
+ const blockLines = pending.concat([line]);
390
+ pending = [];
391
+ i++;
392
+ while (i < lines.length) {
393
+ const next = lines[i];
394
+ if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
395
+ blockLines.push(next);
396
+ i++;
397
+ }
398
+ blocks.push({ anchor, lines: blockLines });
399
+ continue;
400
+ }
401
+ pending.push(line);
402
+ i++;
403
+ }
404
+ return blocks;
405
+ }
406
+
407
+ function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
408
+ const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
409
+ const blocks = parseGrepContextBlocks(norm, filenameOmitted);
410
+ const total = blocks.length;
411
+ if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
412
+ // Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
413
+ // read (stdout cap / stream cap) it is a lower bound, so print ">=T".
414
+ const totalStr = totalKnown ? `${total}` : `>=${total}`;
415
+ const afterOffset = offset > 0 ? blocks.slice(offset) : blocks;
416
+ if (afterOffset.length === 0) {
417
+ // On a partial stream (line cap / timeout) the parsed blocks are a
418
+ // lower bound — an offset beyond them is NOT proven past the last
419
+ // match, so steer toward narrowing instead of claiming "past end".
420
+ const text = totalKnown
421
+ ? `[Showing 0 of ${totalStr} matches; offset ${offset} past end]`
422
+ : `[Showing 0 of ${totalStr} matches (results partial); offset ${offset} is beyond the streamed window — matches past it may exist. Narrow path/glob/pattern instead of paging deeper.]`;
423
+ return { text, total, shown: 0, omitted: 0 };
424
+ }
425
+ const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
426
+ const omitted = afterOffset.length - shown;
427
+ const render = (arr) => arr.map((b) => b.lines.join('\n'));
428
+ let segments;
429
+ let nextOffset = offset + shown;
430
+ if (omitted > 0 && shown > 0) {
431
+ // Keep head + tail so both ends of the match range stay visible.
432
+ const headCount = Math.max(1, Math.ceil(shown / 2));
433
+ const tailCount = shown - headCount;
434
+ const head = render(afterOffset.slice(0, headCount));
435
+ const tail = tailCount > 0 ? render(afterOffset.slice(afterOffset.length - tailCount)) : [];
436
+ segments = [...head, `…${omitted} matches omitted…`, ...tail];
437
+ // Paging must resume at the first OMITTED block (right after the head
438
+ // slice): offset+shown would permanently skip the middle blocks that
439
+ // the tail slice displaced. Tail blocks re-appear on later pages —
440
+ // duplication is acceptable, silent loss is not.
441
+ nextOffset = offset + headCount;
442
+ } else {
443
+ segments = render(afterOffset.slice(0, shown));
444
+ }
445
+ const notice = (omitted > 0 || !totalKnown)
446
+ ? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
447
+ : '';
448
+ return { text: segments.join('\n--\n') + notice, total, shown, omitted };
449
+ }
450
+
451
+ // Part 1: drop path:line match lines already emitted by an earlier pattern in
452
+ // a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
453
+ function dedupeFanoutMatchLines(body, seen) {
454
+ const text = String(body);
455
+ if (/^Error:/.test(text)) return text;
456
+ const out = [];
457
+ for (const line of text.split('\n')) {
458
+ const s = splitGrepLinePrefix(line);
459
+ if (s && s.delimiter === ':') {
460
+ const key = `${s.path}\0${s.lineNo}`;
461
+ if (seen.has(key)) continue;
462
+ seen.add(key);
463
+ }
464
+ out.push(line);
465
+ }
466
+ return out.join('\n');
467
+ }
468
+
364
469
  function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offset, outputMode, patterns: _patterns, beforeN, afterN, contextN, searchPath, grepResolvedPath: _grepResolvedPath, workDir, globPatterns: _globPatterns, fileType: _fileType, filenameOmitted = false, prefix = '', broadAdvisory: _broadAdvisory = true, disableContentGrouping = false }) {
365
470
  const lines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
366
471
  const normalized = lines.map((line) => relativeGrepLine(line, workDir, outputMode === 'files_with_matches', outputMode, filenameOmitted));
367
472
  const remaining = Math.max(0, totalWindowed - lines.length);
368
473
  const shown = lines.length;
369
- const total = totalWindowed;
474
+ // Finding 3: PRE-offset grand total so the denominator matches the
475
+ // context-mode notice (offset==0 leaves this unchanged).
476
+ const total = offset + totalWindowed;
370
477
  const scopePath = JSON.stringify(normalizeOutputPath(searchPath));
371
478
  const truncated = (remaining > 0 || !totalKnown)
372
479
  ? (totalKnown
@@ -604,6 +711,33 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
604
711
  if (fileTypes.length > 1) fileType = fileTypes;
605
712
  else if (fileTypes.length === 1) fileType = fileTypes[0];
606
713
 
714
+ // Part 1: pattern[] fan-out. Two or more patterns in a content search run
715
+ // as INDEPENDENT greps (mirroring the path[] batching above): each pattern
716
+ // keeps its own full head_limit budget and its own truncation notice, and
717
+ // identical path:line match lines are de-duplicated across patterns. The
718
+ // single-pattern path and the non-content modes (files_with_matches/count)
719
+ // keep the combined single-rg behavior. `_grepPatternFanout` guards the
720
+ // recursive single-pattern calls from re-entering the fan-out; the internal
721
+ // chunk-merge recursion is likewise skipped.
722
+ if (patterns.length > 1
723
+ && outputMode === 'content'
724
+ && !options._grepChunkMerge
725
+ && !options._grepPatternFanout) {
726
+ const seen = new Set();
727
+ const subOptions = { ...options, _grepPatternFanout: true };
728
+ const parts = [];
729
+ for (const p of patterns) {
730
+ let sub;
731
+ try {
732
+ sub = await executeGrepTool({ ...args, pattern: p }, workDir, executeChildBuiltinTool, readStateScope, subOptions);
733
+ } catch (err) {
734
+ sub = `Error: ${err && err.message ? err.message : err}`;
735
+ }
736
+ parts.push(`# grep pattern:${JSON.stringify(p)}\n${dedupeFanoutMatchLines(sub, seen)}`);
737
+ }
738
+ return patternCapNote + parts.join('\n\n');
739
+ }
740
+
607
741
  const patternChunkCap = multilineMode ? GREP_MULTILINE_PATTERN_CAP : GREP_ARRAY_PATTERN_CAP;
608
742
  if (patterns.length > patternChunkCap) {
609
743
  const patternChunks = chunkPatternList(patterns, patternChunkCap);
@@ -676,7 +810,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
676
810
  });
677
811
  }
678
812
 
679
- const forceGrepFilename = !!options._grepChunkMerge;
813
+ const forceGrepFilename = !!options._grepChunkMerge || !!options._grepPatternFanout;
680
814
  const cacheKey = buildGrepCacheKey({
681
815
  patterns,
682
816
  searchPath: normalizeOutputPath(grepResolvedPath),
@@ -775,6 +909,64 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
775
909
  pcre2: pcre2Mode,
776
910
  withFilename: forceGrepFilename,
777
911
  });
912
+ // Parts 2 & 3: context mode windows MATCH BLOCKS (not raw lines); the
913
+ // total match count and tail blocks come from the streamed (memory-
914
+ // bounded) collection below.
915
+ const contextMode = outputMode === 'content' && (beforeN > 0 || afterN > 0 || contextN > 0);
916
+ if (contextMode) {
917
+ // Finding 1: stream only enough lines to satisfy the block window
918
+ // (offset + head_limit + tail reserve), so rg is stopped early and a
919
+ // broad content_with_context never retains a full 20MB stdout copy.
920
+ // A cap hit → complete:false → partial (lower-bound) phrasing below.
921
+ const GREP_CONTEXT_LINE_HARD_CAP = 4000;
922
+ const perBlock = 2 + (beforeN || 0) + (afterN || 0) + 2 * (contextN || 0);
923
+ const blockBudget = headLimit === Infinity ? Infinity : offset + headLimit + 4;
924
+ const lineCap = blockBudget === Infinity
925
+ ? GREP_CONTEXT_LINE_HARD_CAP
926
+ : Math.min(GREP_CONTEXT_LINE_HARD_CAP, Math.max(200, blockBudget * Math.max(1, perBlock) + 8));
927
+ let ctxPartialSuffix = '';
928
+ const streamed = await runRgWindowedLines(rgArgs, { cwd: rgSpawnCwd }, { offset: 0, limit: lineCap, summaryLimit: 0 });
929
+ const allLines = streamed.lines;
930
+ let ctxTotalKnown = streamed.complete;
931
+ if (streamed.partial) {
932
+ ctxTotalKnown = false;
933
+ ctxPartialSuffix = streamed.timeout
934
+ ? '\n[warning] rg timed out; partial results shown. Narrow path/glob/pattern for a complete result.'
935
+ : streamed.rgStderr
936
+ ? `\n[warning] rg exit 2 (partial results): ${String(streamed.rgStderr).trim().slice(0, 300)}`
937
+ : '\n[warning] rg exit 2 (partial results)';
938
+ } else if (!streamed.complete) {
939
+ ctxPartialSuffix = `\n[warning] context output capped at ${lineCap} lines to bound memory; results partial — narrow path/glob/pattern for the full match set.`;
940
+ }
941
+ const ctx = formatGrepContextOutput({
942
+ allLines,
943
+ workDir,
944
+ outputMode,
945
+ filenameOmitted,
946
+ headLimit,
947
+ offset,
948
+ totalKnown: ctxTotalKnown,
949
+ });
950
+ let ctxBody = ctx.text;
951
+ if (!ctxBody) {
952
+ const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
953
+ const globStr = normalizedGlobPatterns.length > 0 ? ` glob=${JSON.stringify(normalizedGlobPatterns)}` : '';
954
+ const pathInfo = grepStat.isDirectory() ? 'path exists (dir)' : 'path exists (file)';
955
+ ctxBody = `(no matches) pattern=${patternStr} path=${searchPath}${globStr}; ${pathInfo}`;
956
+ }
957
+ const ctxOut = patternCapNote + ctxBody + ctxPartialSuffix;
958
+ if (options?.scopedCacheOutcome && (!ctxTotalKnown || ctx.omitted > 0)) {
959
+ markScopedCacheIncomplete(options.scopedCacheOutcome);
960
+ }
961
+ recordGrepReadSnapshot(grepStat);
962
+ if (ctxTotalKnown && ctx.omitted === 0) {
963
+ cacheSet(cacheKey, ctxOut, { scopes: [grepResolvedPath] });
964
+ }
965
+ if (typeof options?.onProgress === 'function') {
966
+ try { options.onProgress(`found ${ctx.total} matches`); } catch { /* best-effort */ }
967
+ }
968
+ return ctxOut;
969
+ }
778
970
  let windowed;
779
971
  let totalWindowed = 0;
780
972
  let totalKnown = true;
@@ -832,7 +1024,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
832
1024
  globPatterns: normalizedGlobPatterns,
833
1025
  fileType,
834
1026
  filenameOmitted,
835
- disableContentGrouping: !!options._grepChunkMerge,
1027
+ disableContentGrouping: !!options._grepChunkMerge || !!options._grepPatternFanout,
836
1028
  });
837
1029
  if (!body) {
838
1030
  const pathInfo = grepStat.isDirectory() ? 'path exists (dir)' : 'path exists (file)';
@@ -1,8 +1,41 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
2
2
  import * as fsPromises from 'fs/promises';
3
- import { join } from 'path';
3
+ import { basename, join } from 'path';
4
4
  import { getPluginData } from '../../config.mjs';
5
5
 
6
+ // Bounded tail kept for a job's spilled stdout/stderr. A flooding job could
7
+ // otherwise leave a 100MB+ .stdout.log/.stderr.log sitting for up to the 24h
8
+ // stale TTL. Trimming to a few-MB tail preserves the most recent output
9
+ // (where the final error/exit context lives) while capping on-disk growth.
10
+ const SHELL_JOB_SPILL_MAX_BYTES = 4 * 1024 * 1024;
11
+ const SHELL_JOB_SPILL_KEEP_BYTES = 4 * 1024 * 1024;
12
+
13
+ // Tail-trim a single spill log in place: if it exceeds maxBytes, rewrite it
14
+ // keeping only the last keepBytes. Sync, best-effort, no-op on a missing file.
15
+ // Mirrors rotateBoundedLog in src/lib/mixdog-debug.cjs. Returns true if trimmed.
16
+ export function trimShellJobSpillFile(filePath, maxBytes = SHELL_JOB_SPILL_MAX_BYTES, keepBytes = SHELL_JOB_SPILL_KEEP_BYTES) {
17
+ try {
18
+ const st = statSync(filePath);
19
+ if (st.size <= maxBytes) return false;
20
+ const buf = readFileSync(filePath);
21
+ writeFileSync(filePath, buf.subarray(Math.max(0, buf.length - keepBytes)));
22
+ return true;
23
+ } catch { return false; }
24
+ }
25
+
26
+ // Trim a completed job's stdout+stderr spill files to a bounded tail. Called on
27
+ // every terminal transition so a completed flooding job can never leave a
28
+ // 100MB spill behind for the sweep to reclaim a day later. Uses the detail's
29
+ // recorded paths — adopted foreground jobs spill to shell-output/, not the
30
+ // shell-jobs dir — and dedupes when mergeStderr collapses both onto stdoutPath.
31
+ export function trimShellJobSpill(detail) {
32
+ if (!detail) return;
33
+ const stdoutPath = detail.stdoutPath || shellJobStdoutPath(detail.jobId);
34
+ const stderrPath = detail.stderrPath || shellJobStderrPath(detail.jobId);
35
+ trimShellJobSpillFile(stdoutPath);
36
+ if (stderrPath && stderrPath !== stdoutPath) trimShellJobSpillFile(stderrPath);
37
+ }
38
+
6
39
  // One-shot sweep of stale shell-job artefacts. Each backgrounded `bash`
7
40
  // emits five files (.json/.done/.exit/.stdout.log/.stderr.log); the .done
8
41
  // flag is written when the job exits, so a .done file older than
@@ -82,6 +115,79 @@ async function sweepStaleShellJobs(dir) {
82
115
  ),
83
116
  ...ownerMarkers.map((n) => fsPromises.unlink(join(dir, n)).catch(() => {})),
84
117
  ]);
118
+ // Size enforcement for survivors: a completed flooding job whose .done is
119
+ // still younger than the stale cutoff isn't expired above, yet its spill
120
+ // can hold 100MB+ for up to a day. Proof-of-death is the .done marker (NOT
121
+ // mtime): mtime gating both truncated LIVE-but-quiet jobs whose writer
122
+ // handle is still open — sparse-regrow corruption — AND waited a full day
123
+ // to trim completed floods. Gating on doneSet means we only rewrite a file
124
+ // whose producer has provably exited, which also retries kill-path trims
125
+ // that failed silently while the killed child still held the redirect
126
+ // handle on Windows (trimShellJobSpillFile swallows the lock error).
127
+ await Promise.all(names.map(async (name) => {
128
+ const isStdout = name.endsWith('.stdout.log');
129
+ if (!isStdout && !name.endsWith('.stderr.log')) return;
130
+ const jobId = name.slice(0, -11); // both suffixes are 11 chars
131
+ if (expiredSet.has(jobId)) return; // already unlinked above
132
+ if (!doneSet.has(jobId)) return; // no completion marker → maybe live, never touch
133
+ const p = join(dir, name);
134
+ try {
135
+ const st = await fsPromises.stat(p);
136
+ if (st.size <= SHELL_JOB_SPILL_MAX_BYTES) return;
137
+ const buf = await fsPromises.readFile(p);
138
+ await fsPromises.writeFile(p, buf.subarray(Math.max(0, buf.length - SHELL_JOB_SPILL_KEEP_BYTES)));
139
+ } catch {}
140
+ }));
141
+ sweepStaleShellOutput(dir, names);
142
+ }
143
+
144
+ // Sibling GC for spill files under $PLUGIN_DATA/shell-output/. TaskOutput
145
+ // (shell-command.mjs) spills foreground stdout/stderr there as <taskId>.stdout
146
+ // /.stderr once past the inline cap; a KEPT foreground spill (child dead at
147
+ // settle) or an adopted job's leftover then sits at up to the 100MB disk cap
148
+ // with no sweep of its own. Proof-of-death required before touching a file
149
+ // (an open writer handle is the only corruption/ENOENT-to-a-reader risk): a
150
+ // file is skipped iff some shell-job with a live pid and no .done references
151
+ // it. pid-reuse false positives err toward NOT touching. Dead files past the
152
+ // stale TTL are removed; oversized dead files are trimmed to a bounded tail.
153
+ async function sweepStaleShellOutput(shellJobsDir, jobNames) {
154
+ const outDir = join(getPluginData(), 'shell-output');
155
+ let outNames;
156
+ try { outNames = await fsPromises.readdir(outDir); } catch { return; }
157
+ if (outNames.length === 0) return;
158
+ const doneSet = new Set(jobNames.filter(n => n.endsWith('.done')).map(n => n.slice(0, -5)));
159
+ const liveSpill = new Set();
160
+ await Promise.all(jobNames.map(async (name) => {
161
+ if (!name.endsWith('.json')) return;
162
+ const jobId = name.slice(0, -5);
163
+ if (doneSet.has(jobId)) return; // completed → its spill is provably dead
164
+ try {
165
+ const detail = JSON.parse(await fsPromises.readFile(join(shellJobsDir, name), 'utf-8'));
166
+ const pid = Number(detail?.pid);
167
+ let alive = true; // unknown/invalid pid → conservative: treat as live
168
+ if (Number.isFinite(pid) && pid > 0) {
169
+ try { process.kill(pid, 0); alive = true; } // running (or EPERM)
170
+ catch (e) { alive = e?.code !== 'ESRCH'; } // ESRCH → dead
171
+ }
172
+ if (!alive) return;
173
+ for (const key of ['stdoutPath', 'stderrPath']) {
174
+ const p = detail?.[key];
175
+ if (typeof p === 'string' && p) liveSpill.add(basename(p));
176
+ }
177
+ } catch {}
178
+ }));
179
+ const cutoff = Date.now() - SHELL_JOB_STALE_MS;
180
+ await Promise.all(outNames.map(async (name) => {
181
+ if (liveSpill.has(name)) return; // referenced by a live producer — never touch
182
+ const p = join(outDir, name);
183
+ try {
184
+ const st = await fsPromises.stat(p);
185
+ if (st.mtimeMs < cutoff) { await fsPromises.unlink(p).catch(() => {}); return; }
186
+ if (st.size <= SHELL_JOB_SPILL_MAX_BYTES) return;
187
+ const buf = await fsPromises.readFile(p);
188
+ await fsPromises.writeFile(p, buf.subarray(Math.max(0, buf.length - SHELL_JOB_SPILL_KEEP_BYTES)));
189
+ } catch {}
190
+ }));
85
191
  }
86
192
 
87
193
  export function getShellJobsDir() {
@@ -102,7 +102,19 @@ export function _installShellJobsExitHook() {
102
102
  _shellJobsExitHookInstalled = true;
103
103
  _ensureProcessListenerHeadroom(['exit', 'SIGTERM', 'SIGINT', 'SIGHUP'], 1);
104
104
  try { process.on('exit', _sweepLiveJobsSync); } catch { /* ignore */ }
105
- try { process.on('SIGTERM', _sweepLiveJobsSync); } catch { /* ignore */ }
106
- try { process.on('SIGINT', _sweepLiveJobsSync); } catch { /* ignore */ }
107
- try { process.on('SIGHUP', _sweepLiveJobsSync); } catch { /* ignore */ }
105
+ // For terminating signals, sweep then restore default POSIX termination
106
+ // only when we are the last handler. A sweep-only handler swallows the
107
+ // signal and keeps the process alive; when several such handlers coexist,
108
+ // each removes itself and the last one to run re-raises so the default
109
+ // action takes effect without preempting graceful-shutdown listeners.
110
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
111
+ const onSignal = () => {
112
+ _sweepLiveJobsSync();
113
+ try { process.removeListener(sig, onSignal); } catch { /* ignore */ }
114
+ try {
115
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
116
+ } catch { /* ignore */ }
117
+ };
118
+ try { process.on(sig, onSignal); } catch { /* ignore */ }
119
+ }
108
120
  }
@@ -27,6 +27,7 @@ import {
27
27
  shellJobDonePath,
28
28
  shellJobEnforcedPath,
29
29
  resolveJobOwnerHostPid,
30
+ trimShellJobSpill,
30
31
  writeShellJobDetail,
31
32
  readShellJobDetail,
32
33
  } from './shell-job-paths.mjs';
@@ -309,6 +310,7 @@ export function killShellJob(jobId) {
309
310
  detail.exitCode = 137;
310
311
  detail.error = 'killed by user (KillShell)';
311
312
  detail.finishedAt = new Date().toISOString();
313
+ trimShellJobSpill(detail);
312
314
  writeShellJobDetail(detail);
313
315
  _unregisterLiveJobPid(detail.pid);
314
316
  return { ...attachJobInsights(detail), killed: true };
@@ -340,6 +342,9 @@ function refreshShellJob(jobId) {
340
342
  detail.status = exitCode === 0 ? 'completed' : 'failed';
341
343
  detail.exitCode = exitCode;
342
344
  detail.finishedAt = finishedAt;
345
+ // Job finished: cap its spill to a bounded tail so a flooding
346
+ // producer can't leave a 100MB stdout/stderr log behind.
347
+ trimShellJobSpill(detail);
343
348
  writeShellJobDetail(detail);
344
349
  _unregisterLiveJobPid(detail.pid);
345
350
  return detail;
@@ -352,6 +357,7 @@ function refreshShellJob(jobId) {
352
357
  detail.exitCode = 124;
353
358
  detail.finishedAt = new Date().toISOString();
354
359
  detail.error = `timed out after ${timeoutMs} ms`;
360
+ trimShellJobSpill(detail);
355
361
  writeShellJobDetail(detail);
356
362
  _unregisterLiveJobPid(detail.pid);
357
363
  return detail;
@@ -365,6 +371,7 @@ function refreshShellJob(jobId) {
365
371
  detail.exitCode = 137;
366
372
  detail.finishedAt = new Date().toISOString();
367
373
  detail.error = `output exceeded ${SHELL_JOB_OUTPUT_DISK_CAP} byte cap`;
374
+ trimShellJobSpill(detail);
368
375
  writeShellJobDetail(detail);
369
376
  _unregisterLiveJobPid(detail.pid);
370
377
  return detail;
@@ -193,9 +193,31 @@ function firstExistingPathFromWhereExcluding(commandName, excludeRe) {
193
193
  }
194
194
  }
195
195
 
196
+ // Kind-aware shell resolution. kind:
197
+ //
198
+ // Resolve a real bash on macOS/Linux. When 'bash' is explicitly requested we
199
+ // must NOT hand back /bin/sh, which on dash/ash distros is not bash and breaks
200
+ // bash-only syntax. Probe common install paths, then `bash` on PATH; only when
201
+ // nothing is found do we fall back to /bin/sh so a shell is still returned.
202
+ function resolvePosixBash() {
203
+ for (const p of ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash']) {
204
+ if (existsSync(p)) return shellSpec(p, 'posix');
205
+ }
206
+ try {
207
+ const r = spawnSync('which', ['bash'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 });
208
+ if (r.status === 0 && r.stdout) {
209
+ const p = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).find(Boolean);
210
+ if (p && existsSync(p)) return shellSpec(p, 'posix');
211
+ }
212
+ } catch { /* fall through to /bin/sh */ }
213
+ return shellSpec('/bin/sh', 'posix');
214
+ }
215
+
196
216
  // Kind-aware shell resolution. kind:
197
217
  // 'default' → identical to resolveShell() (PowerShell on Windows, /bin/sh elsewhere).
198
- // 'bash' → on Windows, Git Bash (or null if not installed); elsewhere /bin/sh (POSIX is already bash-compatible).
218
+ // 'bash' → on Windows, Git Bash (or null if not installed); elsewhere a real bash
219
+ // binary (/bin/bash, /usr/bin/bash, or `bash` on PATH), falling back to
220
+ // /bin/sh only when no bash exists (dash/ash distros break on bash syntax).
199
221
  // 'powershell' → on Windows, resolveShell(); elsewhere pwsh if present, else null.
200
222
  // Each kind is memoized independently, but ONLY on success: a resolution miss
201
223
  // (null) is not cached, so the caller's clear-error path is re-probed on the
@@ -206,7 +228,7 @@ export function resolveShellFor(kind = 'default') {
206
228
 
207
229
  let spec = null;
208
230
  if (kind === 'bash') {
209
- spec = _isWindows() ? resolveWindowsGitBash() : shellSpec('/bin/sh', 'posix');
231
+ spec = _isWindows() ? resolveWindowsGitBash() : resolvePosixBash();
210
232
  } else if (kind === 'powershell') {
211
233
  if (_isWindows()) {
212
234
  spec = resolveShell();
@@ -297,6 +297,7 @@ const EXTERNAL_TOOL_REDIRECTS = new Map([
297
297
  ['codebase_search', 'use the `grep` or `code_graph` tool'],
298
298
  ['semanticsearch', 'use the `grep` or `code_graph` tool'],
299
299
  ['semantic_search', 'use the `grep` or `code_graph` tool'],
300
+ ['explore', 'not available in this role — use `grep` or `code_graph` for repo search'],
300
301
  ]);
301
302
 
302
303
  export function canonicalizeBuiltinToolName(name) {
@@ -520,7 +521,22 @@ export async function executeBuiltinTool(name, args, cwd, options = {}) {
520
521
  }
521
522
  })();
522
523
  const _withNudge = _maybeAppendGrepSweepNudge(toolName, args, _toolResult, readStateScope);
523
- return capToolOutput(_withNudge, options);
524
+ return capToolOutput(_appendClampNotices(args, _withNudge), options);
525
+ }
526
+
527
+ // Surface arg-guard clamp notices (args._clampNotices, see pushClampNotice in
528
+ // arg-guard.mjs) on the result text so the caller learns its request was
529
+ // capped. Delete to consume: prevents double-append when a child-builtin
530
+ // result is embedded in the parent's, and leaves no `_clampNotices` residue
531
+ // on the recorded tool-call args in the transcript.
532
+ function _appendClampNotices(args, result) {
533
+ let notices = null;
534
+ if (args && Array.isArray(args._clampNotices)) {
535
+ notices = args._clampNotices;
536
+ delete args._clampNotices;
537
+ }
538
+ if (!notices || notices.length === 0 || typeof result !== 'string') return result;
539
+ return `${result}\n\n${notices.map((n) => `[arg-guard] ${n}`).join('\n')}`;
524
540
  }
525
541
  /**
526
542
  * Check if a tool name is a builtin tool.
@@ -51,6 +51,36 @@ function _collectGraphSymbolList(args) {
51
51
 
52
52
  const CODE_GRAPH_FILE_BATCH_CAP = 20;
53
53
 
54
+ // Absorb: file/files arriving as a JSON-stringified array
55
+ // (file:"[\"a.mjs\",\"b.mjs\"]") — parse to a real array so the graph lookup
56
+ // batches per file instead of treating the JSON text as one (missing) path.
57
+ function _parseJsonArrayString(v) {
58
+ if (typeof v !== 'string') return null;
59
+ const t = v.trim();
60
+ if (!t.startsWith('[') || !t.endsWith(']')) return null;
61
+ try {
62
+ const parsed = JSON.parse(t);
63
+ if (Array.isArray(parsed)) return parsed.map((x) => String(x || '').trim()).filter(Boolean);
64
+ } catch { /* not JSON — leave untouched */ }
65
+ return null;
66
+ }
67
+
68
+ function _normalizeGraphFileArgs(args) {
69
+ if (!args || typeof args !== 'object') return args;
70
+ const fileArr = _parseJsonArrayString(args.file);
71
+ const filesArr = _parseJsonArrayString(args.files);
72
+ if (!fileArr && !filesArr) return args;
73
+ const out = { ...args };
74
+ if (fileArr) { out.files = Array.isArray(out.files) ? [...fileArr, ...out.files] : fileArr; delete out.file; }
75
+ if (filesArr) out.files = filesArr;
76
+ // Collapse a lone entry back to the single-file field for the fast path.
77
+ if (Array.isArray(out.files) && out.files.length === 1 && !out.file) {
78
+ out.file = out.files[0];
79
+ delete out.files;
80
+ }
81
+ return out;
82
+ }
83
+
54
84
  function _collectGraphFileList(args) {
55
85
  const split = (s) => String(s || '').split(/,+/).map((t) => t.trim()).filter(Boolean);
56
86
  const list = [...new Set([
@@ -70,6 +100,13 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
70
100
  let mode = String(args?.mode || '').trim();
71
101
  if (!mode) throw new Error('code_graph: "mode" is required');
72
102
  if (mode === 'search') mode = 'symbol_search';
103
+ // Name-only "symbols" calls (symbols[]/symbol without a file) are symbol
104
+ // lookups, not a file outline — absorb into symbol_search instead of
105
+ // erroring "file not found in graph: (missing file)".
106
+ if (mode === 'symbols' && !String(args?.file || '').trim()
107
+ && ((Array.isArray(args?.symbols) && args.symbols.length) || String(args?.symbol || '').trim())) {
108
+ mode = 'symbol_search';
109
+ }
73
110
 
74
111
  if (mode === 'prewarm') {
75
112
  const _splitMulti = (s) => String(s || '').split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
@@ -436,6 +473,7 @@ export async function resolveSymbolReadSpan(cwd, { symbol, path = null, language
436
473
 
437
474
  export async function executeCodeGraphTool(name, args, cwd, signal = null, options = {}) {
438
475
  if (!cwd) throw new Error('find_symbol/code_graph requires cwd — caller did not provide a working directory');
476
+ args = _normalizeGraphFileArgs(args);
439
477
  const fileArg = (args && typeof args.file === 'string' && args.file.trim()) ? args.file.trim() : '';
440
478
  const baseCwd = (args && typeof args.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
441
479
  let effectiveCwd = baseCwd;
@@ -3,13 +3,13 @@ export const CODE_GRAPH_TOOL_DEFS = [
3
3
  name: 'code_graph',
4
4
  title: 'Code Graph',
5
5
  annotations: { title: 'Code Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false, compressibleLossless: true },
6
- description: 'Repo-local code structure/flow lookup, not web: symbols/references/calls/deps before text grep. Multiple targets = ONE call with symbols[]/files[], never parallel single-target calls.',
6
+ description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps. Batch targets as symbols[]/files[] in one call.',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {
10
- mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers'], description: 'Repo-local operation; code flow before text search.' },
11
- files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file(s): string or array; one call fans out per file (imports/dependents/related/impact/symbols/overview). `file` singular alias also accepted.' },
12
- symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s): string or array; batch in one call. One anchor is enough. `symbol` singular alias also accepted.' },
10
+ mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers'], description: 'Repo-local graph operation.' },
11
+ files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file(s); array fans out per file. `file` alias too.' },
12
+ symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s); array batches in one call. `symbol` alias too.' },
13
13
  symbol: { type: 'string', description: 'Singular alias for symbols.' },
14
14
  file: { type: 'string', description: 'Singular alias for files.' },
15
15
  body: { type: 'boolean', description: 'Include body.' },
@@ -11,7 +11,7 @@ import { withBuiltinPathLocks } from '../builtin.mjs';
11
11
  import { withAdvisoryLocks } from '../builtin/advisory-lock.mjs';
12
12
  import { wrapMutationRouteOutput } from '../mutation-planner.mjs';
13
13
  import { getPluginData } from '../../config.mjs';
14
- import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, isCompactedPlaceholderPatch } from './parsing.mjs';
14
+ import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, isCompactedPlaceholderPatch, salvageV4AOpening } from './parsing.mjs';
15
15
  import {
16
16
  resolveBasePath,
17
17
  resolveV4AEntryPath,
@@ -105,7 +105,7 @@ function salvageShatteredV4APatchArgs(args) {
105
105
 
106
106
  async function apply_patch(args, cwd, options = {}) {
107
107
  args = salvageShatteredV4APatchArgs(args);
108
- const patchStr = (typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, '');
108
+ const patchStr = salvageV4AOpening((typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, ''));
109
109
  if (!patchStr.trim()) {
110
110
  throw new Error('apply_patch: "patch" is required (unified diff or V4A patch string)');
111
111
  }