mixdog 0.9.18 → 0.9.19

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 (122) hide show
  1. package/package.json +3 -2
  2. package/scripts/output-style-smoke.mjs +2 -2
  3. package/scripts/recall-bench-cases.json +11 -0
  4. package/scripts/recall-bench.mjs +91 -2
  5. package/scripts/tool-efficiency-diag.mjs +4 -1
  6. package/scripts/tool-smoke.mjs +101 -27
  7. package/src/agents/debugger/AGENT.md +3 -3
  8. package/src/agents/heavy-worker/AGENT.md +7 -10
  9. package/src/agents/maintainer/AGENT.md +1 -2
  10. package/src/agents/reviewer/AGENT.md +1 -2
  11. package/src/agents/worker/AGENT.md +5 -8
  12. package/src/defaults/agents.json +3 -0
  13. package/src/mixdog-session-runtime.mjs +23 -6
  14. package/src/rules/agent/00-core.md +4 -3
  15. package/src/rules/agent/30-explorer.md +53 -22
  16. package/src/rules/lead/lead-tool.md +3 -2
  17. package/src/rules/shared/01-tool.md +24 -29
  18. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  21. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  28. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  29. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  30. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  32. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  33. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  34. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  35. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  36. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  37. package/src/runtime/channels/backends/discord.mjs +43 -1
  38. package/src/runtime/channels/index.mjs +145 -58
  39. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  40. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  41. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  42. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  43. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  44. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  45. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  46. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  47. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  48. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  49. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  50. package/src/runtime/shared/atomic-file.mjs +10 -4
  51. package/src/runtime/shared/background-tasks.mjs +4 -2
  52. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  53. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  54. package/src/runtime/shared/tool-surface.mjs +30 -1
  55. package/src/session-runtime/config-lifecycle.mjs +1 -1
  56. package/src/session-runtime/cwd-plugins.mjs +46 -3
  57. package/src/session-runtime/mcp-glue.mjs +24 -3
  58. package/src/session-runtime/output-styles.mjs +44 -10
  59. package/src/session-runtime/workflow.mjs +16 -1
  60. package/src/standalone/channel-worker.mjs +74 -5
  61. package/src/standalone/explore-tool.mjs +1 -1
  62. package/src/tui/App.jsx +57 -77
  63. package/src/tui/app/channel-pickers.mjs +45 -0
  64. package/src/tui/app/slash-commands.mjs +0 -1
  65. package/src/tui/app/slash-dispatch.mjs +0 -16
  66. package/src/tui/app/transcript-window.mjs +44 -1
  67. package/src/tui/app/use-mouse-input.mjs +9 -2
  68. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  69. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  70. package/src/tui/app/use-transcript-window.mjs +65 -5
  71. package/src/tui/components/PromptInput.jsx +33 -64
  72. package/src/tui/components/ToolExecution.jsx +2 -2
  73. package/src/tui/dist/index.mjs +5330 -5443
  74. package/src/tui/engine.mjs +49 -10
  75. package/src/tui/lib/voice-setup.mjs +166 -0
  76. package/src/tui/paste-attachments.mjs +12 -5
  77. package/src/tui/prompt-history-store.mjs +125 -12
  78. package/scripts/bench/cache-probe-tasks.json +0 -8
  79. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  80. package/scripts/bench/lead-review-tasks.json +0 -20
  81. package/scripts/bench/r4-mixed-tasks.json +0 -20
  82. package/scripts/bench/r5-orchestrated-task.json +0 -7
  83. package/scripts/bench/review-tasks.json +0 -20
  84. package/scripts/bench/round-codex.json +0 -114
  85. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  86. package/scripts/bench/round-mixdog-lead.json +0 -269
  87. package/scripts/bench/round-mixdog.json +0 -126
  88. package/scripts/bench/round-r10-bigsample.json +0 -679
  89. package/scripts/bench/round-r11-codexalign.json +0 -257
  90. package/scripts/bench/round-r13-clientmeta.json +0 -464
  91. package/scripts/bench/round-r14-betafeatures.json +0 -466
  92. package/scripts/bench/round-r15-fulldefault.json +0 -462
  93. package/scripts/bench/round-r16-sessionid.json +0 -466
  94. package/scripts/bench/round-r17-wirebytes.json +0 -456
  95. package/scripts/bench/round-r18-prewarm.json +0 -468
  96. package/scripts/bench/round-r19-clean.json +0 -472
  97. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  98. package/scripts/bench/round-r21-delta-retry.json +0 -473
  99. package/scripts/bench/round-r22-full-probe.json +0 -693
  100. package/scripts/bench/round-r23-itemprobe.json +0 -701
  101. package/scripts/bench/round-r24-shapefix.json +0 -677
  102. package/scripts/bench/round-r25-serial.json +0 -464
  103. package/scripts/bench/round-r26-parallel3.json +0 -671
  104. package/scripts/bench/round-r27-parallel10.json +0 -894
  105. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  106. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  107. package/scripts/bench/round-r30-instid.json +0 -253
  108. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  109. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  110. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  111. package/scripts/bench/round-r34-orchestrated.json +0 -120
  112. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  113. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  114. package/scripts/bench/round-r4-codex.json +0 -114
  115. package/scripts/bench/round-r4-mixed.json +0 -225
  116. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  117. package/scripts/bench/round-r6-codex.json +0 -114
  118. package/scripts/bench/round-r6-solo.json +0 -257
  119. package/scripts/bench/round-r7-full.json +0 -254
  120. package/scripts/bench/round-r8-fulldefault.json +0 -255
  121. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  122. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -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)';
@@ -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.' },
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // Claude Code-style shell state carry-over for the one-shot shell tool.
2
+ // Shell state carry-over for the one-shot shell tool.
3
3
  //
4
4
  // No live shell process is kept. Instead, after each sync command we chain a
5
5
  // trailing cwd probe that writes the final working directory to a per-session
@@ -205,13 +205,24 @@ function registerShardListeners(self) {
205
205
 
206
206
  async function awaitLogin(self) {
207
207
  let readyTimeout;
208
+ // Adaptive ready timeout: grow on consecutive login failures (30s→60s→90s
209
+ // cap) so an identify delayed by gateway rate-limiting can still complete
210
+ // its READY handshake instead of tripping a fixed 30s timeout every retry.
211
+ const readyMs = Math.min(90_000, 30_000 + (self._loginFailures || 0) * 30_000);
208
212
  const readyPromise = new Promise((resolve, reject) => {
209
- readyTimeout = setTimeout(() => reject(new Error("discord ready timeout (30s)")), 3e4);
213
+ readyTimeout = setTimeout(
214
+ () => reject(new Error(`discord ready timeout (${Math.round(readyMs / 1000)}s)`)),
215
+ readyMs
216
+ );
210
217
  self.client.once(self._discordEvents().ClientReady, () => {
211
218
  clearTimeout(readyTimeout);
212
219
  resolve();
213
220
  });
214
221
  });
222
+ // Guarantee the ready-timeout rejection can never surface as a process-global
223
+ // unhandledRejection regardless of how the race below settles: attach an
224
+ // absorbing handler; the real handling still happens via the awaits.
225
+ readyPromise.catch(() => {});
215
226
  // Observe the ready timer from the start: login() can hang past the 30s
216
227
  // timer (gateway reconnect), and a rejection on an un-awaited readyPromise
217
228
  // becomes a process-global unhandledRejection → fatal crash path
@@ -225,7 +236,9 @@ async function awaitLogin(self) {
225
236
  try {
226
237
  await Promise.race([loginPromise, readyPromise]);
227
238
  await readyPromise;
239
+ self._loginFailures = 0;
228
240
  } catch (err) {
241
+ self._loginFailures = (self._loginFailures || 0) + 1;
229
242
  clearTimeout(readyTimeout);
230
243
  throw err;
231
244
  }
@@ -28,6 +28,12 @@ const MAX_CHUNK_LIMIT = MAX_DISCORD_MESSAGE;
28
28
  // which upstream leaves the forwarder's `sending` flag stuck and silences all
29
29
  // outbound. On timeout we treat the client as dead and reset+reconnect it.
30
30
  const SEND_ATTEMPT_TIMEOUT_MS = 30_000;
31
+ // Reconnect backoff: consecutive _resetClient failures back off exponentially
32
+ // with jitter up to a cap so a wedged/rate-limited gateway is not hammered with
33
+ // fixed sub-minute destroy()/login() cycles (that fixed cadence triggered
34
+ // Discord identify rate-limiting → the ready-timeout death loop).
35
+ const RESET_BACKOFF_BASE_MS = 2_000;
36
+ const RESET_BACKOFF_CAP_MS = 60_000;
31
37
  // After a timed-out send is reset, cap how long we wait for the original
32
38
  // (abandoned) promise to actually settle before giving up with unknown outcome.
33
39
  const SETTLE_CAP_MS = 15_000;
@@ -49,7 +55,12 @@ class DiscordBackend {
49
55
  initialAccess;
50
56
  recentSentIds = /* @__PURE__ */ new Set();
51
57
  sendCount = 0;
58
+ _resetFailures = 0;
59
+ _loginFailures = 0;
52
60
  typingIntervals = /* @__PURE__ */ new Map();
61
+ // In-flight sendMessage() promises, awaited by drainPendingSends() before a
62
+ // handoff disconnect so a reply is not cut off mid-delivery.
63
+ _pendingSends = /* @__PURE__ */ new Set();
53
64
  constructor(config, stateDir) {
54
65
  this.token = config.token;
55
66
  this.mainChannelId = config.mainChannelId ?? "";
@@ -114,11 +125,24 @@ class DiscordBackend {
114
125
  async _resetClient() {
115
126
  if (this._resetPromise) return this._resetPromise;
116
127
  this._resetPromise = (async () => {
128
+ // Back off before rebuilding: on repeated reset failures wait an
129
+ // exponentially growing (jittered) delay capped at RESET_BACKOFF_CAP_MS
130
+ // so we never re-identify in a tight loop.
131
+ const n = this._resetFailures || 0;
132
+ if (n > 0) {
133
+ const cap = Math.min(RESET_BACKOFF_CAP_MS, RESET_BACKOFF_BASE_MS * 2 ** (n - 1));
134
+ const delay = Math.floor(cap / 2 + Math.random() * (cap / 2));
135
+ await new Promise((r) => setTimeout(r, delay));
136
+ }
117
137
  try { this.client?.destroy?.(); } catch {}
118
138
  this.client = null;
119
139
  this._connectPromise = null;
120
140
  await this.connect();
121
- })().finally(() => { this._resetPromise = null; });
141
+ this._resetFailures = 0;
142
+ })().catch((err) => {
143
+ this._resetFailures = (this._resetFailures || 0) + 1;
144
+ throw err;
145
+ }).finally(() => { this._resetPromise = null; });
122
146
  return this._resetPromise;
123
147
  }
124
148
  /** Health-check the gateway; reset+reconnect if the ws is not READY. Called
@@ -154,7 +178,25 @@ class DiscordBackend {
154
178
  }
155
179
  }
156
180
  // ── Outbound operations ────────────────────────────────────────────
181
+ // Public entry: tracks the send so drainPendingSends() can await in-flight
182
+ // deliveries before an ownership-handoff disconnect. Delegates to the impl.
157
183
  async sendMessage(chatId, text, opts) {
184
+ const p = this._sendMessageImpl(chatId, text, opts);
185
+ const tracked = Promise.resolve(p).catch(() => {});
186
+ this._pendingSends.add(tracked);
187
+ tracked.finally(() => this._pendingSends.delete(tracked));
188
+ return p;
189
+ }
190
+ /** Await in-flight sends (bounded) so a handoff drains outbound before
191
+ * disconnect. Bounded by timeoutMs so a wedged send can't stall teardown. */
192
+ async drainPendingSends(timeoutMs = 4000) {
193
+ if (this._pendingSends.size === 0) return;
194
+ await Promise.race([
195
+ Promise.allSettled([...this._pendingSends]),
196
+ new Promise((r) => setTimeout(r, timeoutMs)),
197
+ ]);
198
+ }
199
+ async _sendMessageImpl(chatId, text, opts) {
158
200
  const ch = await this.fetchAllowedChannel(chatId);
159
201
  if (!("send" in ch)) throw new Error("channel is not sendable");
160
202
  const files = opts?.files ?? [];