mixdog 0.9.93 → 0.9.94

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/rules/agent/30-explorer.md +22 -16
  3. package/src/rules/shared/01-tool.md +23 -18
  4. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +22 -6
  5. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  6. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  7. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  8. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +100 -86
  9. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  10. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  11. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +3 -3
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -4
  14. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  15. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  18. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  19. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +6 -1
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  23. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  24. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +543 -19
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +5 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  28. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  30. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +4 -2
  31. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +51 -2
  32. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  33. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  34. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +3 -3
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +3 -3
  36. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  37. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  38. package/src/runtime/shared/background-tasks.mjs +10 -3
  39. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  40. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  41. package/src/runtime/shared/tool-card-model.mjs +6 -2
  42. package/src/runtime/shared/tool-surface.mjs +7 -2
  43. package/src/session-runtime/provider-usage.mjs +26 -2
  44. package/src/standalone/explore-tool.mjs +1 -1
  45. package/src/tui/app/use-transcript-window.mjs +7 -1
  46. package/src/tui/components/Spinner.jsx +18 -9
  47. package/src/tui/dist/index.mjs +111 -25
  48. package/src/tui/engine/live-share.mjs +23 -3
  49. package/src/tui/engine/session-api.mjs +7 -0
  50. package/src/tui/engine/turn.mjs +77 -4
  51. package/src/tui/engine.mjs +14 -6
  52. package/src/tui/index.jsx +7 -0
@@ -14,7 +14,7 @@ import { markSessionToolCall, updateSessionStage } from './manager.mjs';
14
14
  import { resolveToolSelfDeadlineMs } from '../agent-runtime/agent-progress-watchdog.mjs';
15
15
  import { classifyResultKind } from './result-classification.mjs';
16
16
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
17
- import { maybeOffloadToolResult } from './tool-result-offload.mjs';
17
+ import { maybeOffloadToolResultBatch } from './tool-result-offload.mjs';
18
18
  import {
19
19
  tryReadCached, setReadCached, invalidatePathForSession, markPostEdit,
20
20
  consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch,
@@ -93,6 +93,16 @@ export async function processToolBatch(ctx) {
93
93
  // between two tool results of the same multi-tool turn (which would put a
94
94
  // user message between tool(A) and tool(B) and break provider pairing).
95
95
  const _batchNewMessages = [];
96
+ // Finalize together after execution so the message-level output budget
97
+ // can select the largest non-Read results before cache/history/UI see
98
+ // any body.
99
+ const _batchCompleted = [];
100
+ const _batchToolResultByCallId = new Map();
101
+ const _batchToolResultsWithoutId = [];
102
+ const _stageToolResultMessage = (message) => {
103
+ if (message?.toolCallId) _batchToolResultByCallId.set(message.toolCallId, message);
104
+ else _batchToolResultsWithoutId.push(message);
105
+ };
96
106
  // Ordered-mutation batch gate. Each apply_patch is an eager-dispatch
97
107
  // barrier: side effects overlap within the current segment, but no
98
108
  // later segment starts before the loop crosses the patch in model
@@ -110,7 +120,7 @@ export async function processToolBatch(ctx) {
110
120
  if (_duplicateCallIds.has(call.id)) {
111
121
  const _firstId = _dupFirstId.get(call.id);
112
122
  const _stub = `[intra-turn-dedup] identical read-only \`${call.name}\` call was already executed in this same assistant turn as tool_use_id=${_firstId}. The first call's tool_result is in context immediately above; skipping re-execution to save tokens. If you needed a different slice of the file, narrow the next call (different path / offset / limit / pattern) so it has a distinct signature.`;
113
- pushToolResultMessage({
123
+ _stageToolResultMessage({
114
124
  role: 'tool',
115
125
  content: _stub,
116
126
  toolCallId: call.id,
@@ -136,7 +146,7 @@ export async function processToolBatch(ctx) {
136
146
  _prior.count += 1;
137
147
  dedupStubTotal += 1;
138
148
  const _stub = crossTurnDedupStub(call.name, _prior.firstIteration, dedupStubTotal >= 5);
139
- pushToolResultMessage({
149
+ _stageToolResultMessage({
140
150
  role: 'tool',
141
151
  content: _stub,
142
152
  toolCallId: call.id,
@@ -175,7 +185,7 @@ export async function processToolBatch(ctx) {
175
185
  {
176
186
  const _rfg = sessionRef?._repeatFailGuard;
177
187
  if (_rfg && _rfg.sig === _repeatFailSig && _rfg.count >= repeatFailLimit) {
178
- pushToolResultMessage({
188
+ _stageToolResultMessage({
179
189
  role: 'tool',
180
190
  content: `[repeat-failure-guard] This exact \`${call.name}\` call (identical arguments) has already failed ${_rfg.count} times in a row; not re-executing because the result will not change. Change approach: use different arguments, a different tool, or skip this step.`,
181
191
  toolCallId: call.id,
@@ -200,7 +210,7 @@ export async function processToolBatch(ctx) {
200
210
  // structural failure that drives retry/stop-hook behavior.
201
211
  if (_orderedMutationFailed && _isOrderedGateSkippable(call.name) && !pending.has(call.id)) {
202
212
  if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
203
- pushToolResultMessage({
213
+ _stageToolResultMessage({
204
214
  role: 'tool',
205
215
  content: `[prerequisite-failed] an earlier apply_patch in this same tool batch (call index ${_orderedMutationFailed.index + 1}) failed, so this later \`${call.name}\` was skipped without starting. Re-issue it after resolving the patch failure.`,
206
216
  toolCallId: call.id,
@@ -516,37 +526,62 @@ export async function processToolBatch(ctx) {
516
526
  if (sessionId && _isShellTool(call.name)) {
517
527
  clearScopedToolsForSession(sessionId);
518
528
  }
519
- // R17 compression pipeline — correct ordering (compress → cache → push):
520
- // 1. compressToolResult: lossless ANSI/dedup/separator passes.
521
- // 2. setReadCached / setScopedToolCached: cache stores the SAME result that
522
- // goes into conversation history. Cache-hit refs point to the tool_use_id
523
- // whose message body matches — no phantom full body.
524
- // 3. offload → hint → message push.
525
- // Offload FIRST — before compress. Large RAW output goes to a disk sidecar
526
- // + ~2K preview before any in-place shrink (lossless compress) can reduce
527
- // it below the offload threshold and pre-empt the sidecar. When offload
528
- // fires it replaces `result` with a short preview stub (<2K) referencing
529
- // the on-disk path; the later compress is a no-op on that stub. compress
530
- // then only touches output that stayed inline (<= threshold).
531
- // Per-tool post-processing backstop. The executeTool try/catch
532
- // above terminates BEFORE offload/compress/trim/hint/cache writes/
533
- // trace/messages.push, so a maybeOffloadToolResult rejection (or
534
- // any downstream throw) would otherwise leave the assistant
535
- // tool_use message with no matching tool result. Wrap the whole
536
- // post-processing window through messages.push() in a catch; on
537
- // failure push a synthetic Error: tool result for this call.id
538
- // and skip the cache writes for it.
539
- let _nativeToolSearch = null;
529
+ _batchCompleted.push({
530
+ call,
531
+ result,
532
+ toolStartedAt,
533
+ toolEndedAt,
534
+ toolKind,
535
+ resultKind: _resultKind,
536
+ executeOk: _executeOk,
537
+ readCacheHit: _readCacheHit,
538
+ scopedCacheHit: _scopedCacheHit,
539
+ crossTurnSig: _ctSig,
540
+ mutationEpoch: epoch.mutation,
541
+ nativeToolSearch: null,
542
+ postError: null,
543
+ });
544
+ // Soft-cancel after each tool: if close landed during execution,
545
+ // discard the rest of the batch and skip the next provider.send.
546
+ throwIfAborted();
547
+ }
548
+ for (const completed of _batchCompleted) {
540
549
  try {
541
- // Offload thresholds are keyed by BARE tool name
542
- // (INLINE_THRESHOLD_BY_TOOL: grep=20k, bash=30k, read=Infinity, ...),
543
- // so strip the MCP prefix exactly as the cache write below does.
544
- // Otherwise an mcp__..__grep name misses its 20k grep cap and
545
- // silently falls back to the 50k default — per-tool limits ignored.
546
- const _toolBare = _stripMcpPrefix(call.name);
547
- _nativeToolSearch = parseNativeToolSearchPayload(call.name, result);
548
- if (_nativeToolSearch?.summary) result = _nativeToolSearch.summary;
549
- result = await maybeOffloadToolResult(sessionId, call.id, _toolBare, result);
550
+ completed.nativeToolSearch = parseNativeToolSearchPayload(completed.call.name, completed.result);
551
+ if (completed.nativeToolSearch?.summary) completed.result = completed.nativeToolSearch.summary;
552
+ } catch (error) {
553
+ completed.postError = error;
554
+ }
555
+ }
556
+ let _offloadStates;
557
+ try {
558
+ _offloadStates = await maybeOffloadToolResultBatch(
559
+ sessionId,
560
+ _batchCompleted.map((completed) => ({
561
+ toolCallId: completed.call.id,
562
+ toolName: _stripMcpPrefix(completed.call.name),
563
+ result: completed.postError ? null : completed.result,
564
+ })),
565
+ );
566
+ } catch (error) {
567
+ _offloadStates = _batchCompleted.map(() => ({ result: null, error }));
568
+ }
569
+ // Assistant-message pipeline: offload → compress → trace → cache →
570
+ // push. The cache and transcript therefore receive the same body.
571
+ for (let completedIndex = 0; completedIndex < _batchCompleted.length; completedIndex += 1) {
572
+ const completed = _batchCompleted[completedIndex];
573
+ const {
574
+ call, toolStartedAt, toolEndedAt, toolKind,
575
+ executeOk: _executeOk, resultKind: _resultKind,
576
+ readCacheHit: _readCacheHit, scopedCacheHit: _scopedCacheHit,
577
+ } = completed;
578
+ let _ctSig = completed.crossTurnSig;
579
+ let result = completed.result;
580
+ const _nativeToolSearch = completed.nativeToolSearch;
581
+ try {
582
+ if (completed.postError) throw completed.postError;
583
+ if (_offloadStates[completedIndex]?.error) throw _offloadStates[completedIndex].error;
584
+ result = _offloadStates[completedIndex]?.result;
550
585
  result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
551
586
  traceAgentTool({
552
587
  sessionId,
@@ -561,44 +596,37 @@ export async function processToolBatch(ctx) {
561
596
  resultText: result,
562
597
  cwd,
563
598
  });
564
- // Cache stores run AFTER compress+trim+offload+hint AND after all other
565
- // post-processing (trace) so stored content == history content. Placing
566
- // the cache writes immediately before messages.push ensures ANY throw
567
- // earlier in post-processing skips the cache entirely — no stale or
568
- // partial result is ever cached. Cache-hit refs pointing to an offloaded
569
- // tool_use will show the offload stub; LLM can still recover the full
570
- // body via the disk path in that stub.
571
- if (sessionId && _executeOk && _resultKind === 'normal') {
599
+ // Deferred writes that predate a later mutation are skipped;
600
+ // immediate writes used to be invalidated by that mutation.
601
+ const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
602
+ ? sessionRef._scopedCacheOutcomeByCallId : null;
603
+ if (sessionId && _executeOk && _resultKind === 'normal' && completed.mutationEpoch === epoch.mutation) {
572
604
  if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
573
- const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
574
- ? sessionRef._scopedCacheOutcomeByCallId : null;
575
605
  const _outcome = _outcomeMap?.get(call.id);
576
606
  setScopedToolCached({
577
607
  sessionId,
578
- toolName: _toolBare,
608
+ toolName: _stripMcpPrefix(call.name),
579
609
  args: call.arguments,
580
610
  cwd,
581
611
  content: result,
582
612
  toolUseId: call.id,
583
613
  complete: _outcome ? _outcome.complete : true,
584
614
  });
585
- _outcomeMap?.delete(call.id);
586
615
  }
587
616
  if (_readCacheHit === null && _isReadTool(call.name)) {
588
- // Pass tool_use id so future cache-hits can reference the body's location in history.
589
617
  setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
590
618
  }
591
619
  }
592
- // UI-only: apply_patch stashes the standard unified diff keyed
593
- // by tool_use id (never in the model-visible result). Attach it
594
- // here as a side-channel field so the TUI's expanded (ctrl+o)
595
- // raw view renders a colored +/- diff. The provider lowering
596
- // (anthropic/openai/etc.) never reads `uiDiff`, so the model
597
- // sees only `content` (the compact summary) — no token bloat.
620
+ // A successful scoped lookup from before a later mutation is
621
+ // intentionally not cached, but its per-call completeness
622
+ // record still has to be reclaimed.
623
+ if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
624
+ _outcomeMap?.delete(call.id);
625
+ }
598
626
  const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
599
627
  ? takeApplyPatchUiDiff(call.id)
600
628
  : null;
601
- pushToolResultMessage({
629
+ _stageToolResultMessage({
602
630
  role: 'tool',
603
631
  content: result,
604
632
  toolCallId: call.id,
@@ -606,10 +634,6 @@ export async function processToolBatch(ctx) {
606
634
  ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
607
635
  ...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
608
636
  });
609
- // Completion-first bookkeeping (Steps 1 & 2). Only successful
610
- // executions count. Edit/progress = any executed tool whose def
611
- // lacks readOnlyHint (apply_patch/bash/MCP-write/skill/...).
612
- // Read-only successful calls seed the cross-turn dedup map.
613
637
  if (_executeOk) {
614
638
  const _isEager = isEagerDispatchable(call.name, tools);
615
639
  if (isToolCallDedupEligible(call.name, tools)) {
@@ -621,30 +645,19 @@ export async function processToolBatch(ctx) {
621
645
  crossTurnCalls.delete(_oldest);
622
646
  }
623
647
  }
624
- } else if (!_isEager) {
625
- // A successful mutating (non-eager) tool invalidates the
626
- // cross-turn dedup map wholesale: any prior read/grep may
627
- // now return different content, so a post-edit
628
- // verification read must NOT be stubbed as "unchanged".
629
- if (isEditProgressTool(call.name, false)) {
630
- crossTurnCalls.clear();
631
- editCount += 1;
632
- }
648
+ } else if (!_isEager && isEditProgressTool(call.name, false)) {
649
+ crossTurnCalls.clear();
650
+ editCount += 1;
633
651
  }
634
652
  }
635
653
  } catch (postErr) {
636
- // Reviewer fix: the exec itself succeeded — if it was a
637
- // mutating edit-progress tool, the file changes are real even
638
- // though post-processing failed, so the cross-turn dedup map
639
- // must still be invalidated (otherwise a later verification
640
- // read could be stubbed as "unchanged" against stale sigs).
641
654
  if (_executeOk && !isEagerDispatchable(call.name, tools) && isEditProgressTool(call.name, false)) {
642
655
  crossTurnCalls.clear();
643
656
  editCount += 1;
644
657
  }
645
- // Post-processing failed AFTER a successful exec: the result is
646
- // replaced with an error below, so preserve this call's full body
647
- // too for a clean retry (mirrors the failed-exec path above).
658
+ if (sessionRef?._scopedCacheOutcomeByCallId instanceof Map && call?.id) {
659
+ sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
660
+ }
648
661
  if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
649
662
  const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
650
663
  traceAgentToolFailure({
@@ -660,24 +673,25 @@ export async function processToolBatch(ctx) {
660
673
  resultText: _postMsg,
661
674
  resultKind: 'error',
662
675
  });
663
- // Always emit a matching tool result so the assistant
664
- // tool_use isn't orphaned. Cache writes are placed at the
665
- // end of the try block (immediately before messages.push),
666
- // so ANY throw in post-processing reaches this catch before
667
- // the cache is written — stale/partial results are never
668
- // cached. The next read on the same path/scope re-executes
669
- // naturally.
670
- pushToolResultMessage({
676
+ _stageToolResultMessage({
671
677
  role: 'tool',
672
678
  content: _postMsg,
673
679
  toolCallId: call.id,
674
680
  toolKind: 'error',
675
681
  });
676
682
  }
677
- // Soft-cancel after each tool: if close landed during execution,
678
- // discard the rest of the batch and skip the next provider.send.
679
683
  throwIfAborted();
680
684
  }
685
+ // Early dedup/guard skips and deferred execution results share this
686
+ // ordered flush so provider tool_result blocks always follow the
687
+ // assistant's original tool_use order.
688
+ for (const call of calls) {
689
+ const message = _batchToolResultByCallId.get(call?.id);
690
+ if (message) pushToolResultMessage(message);
691
+ }
692
+ for (const message of _batchToolResultsWithoutId) {
693
+ pushToolResultMessage(message);
694
+ }
681
695
  // Flush the per-batch newMessages channel. All tool_results for this
682
696
  // assistant turn are now pushed; appending the injected role:'user'
683
697
  // messages here (AFTER the last tool_result, BEFORE the next provider
@@ -10,6 +10,7 @@ const TOOL_RESULT_PREVIEW_CHARS = 2_000;
10
10
  const TOOL_RESULT_SHELL_THRESHOLD_CHARS = 30_000;
11
11
  const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
12
12
  const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
13
+ export const TOOL_RESULT_MESSAGE_MAX_CHARS = 200_000;
13
14
  const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
14
15
  const OFFLOAD_PRUNE_MIN_AGE_MS = 10 * 60 * 1000;
15
16
  // clearOffloadSession only runs on a CLEAN session close, so every killed
@@ -86,6 +87,35 @@ function getOffloadThreshold(toolName) {
86
87
  return INLINE_THRESHOLD_BY_TOOL.get(key) ?? TOOL_RESULT_OFFLOAD_THRESHOLD_CHARS;
87
88
  }
88
89
 
90
+ const AGGREGATE_OFFLOAD_EXCLUDED_TOOLS = new Set([
91
+ 'read',
92
+ 'head',
93
+ 'tail',
94
+ 'diff',
95
+ 'skill',
96
+ 'skill_view',
97
+ 'skills_list',
98
+ ]);
99
+
100
+ export function isAggregateOffloadEligible(toolName, result) {
101
+ if (typeof result !== 'string') return false;
102
+ const key = String(toolName || '').toLowerCase();
103
+ return !AGGREGATE_OFFLOAD_EXCLUDED_TOOLS.has(key);
104
+ }
105
+
106
+ export function rankAggregateOffloadCandidates(entries) {
107
+ return entries
108
+ .map((entry, index) => ({
109
+ index,
110
+ length: typeof entry?.result === 'string' ? entry.result.length : 0,
111
+ eligible: isAggregateOffloadEligible(entry?.toolName, entry?.result)
112
+ && !String(entry?.result || '').startsWith(TOOL_RESULT_OFFLOAD_PREFIX),
113
+ }))
114
+ .filter((entry) => entry.eligible)
115
+ .sort((a, b) => b.length - a.length || b.index - a.index)
116
+ .map((entry) => entry.index);
117
+ }
118
+
89
119
  // Sanitize sessionId before using it as a path segment. A raw `..` or slash
90
120
  // would let the sidecar dir — and clearOffloadSession's readdir+unlink — escape
91
121
  // the tool-results root (arbitrary .txt deletion). Strip to [A-Za-z0-9_-];
@@ -139,15 +169,17 @@ function countLines(text) {
139
169
  return lines;
140
170
  }
141
171
 
142
- export async function maybeOffloadToolResult(sessionId, toolCallId, toolName, result) {
172
+ export async function maybeOffloadToolResult(sessionId, toolCallId, toolName, result, options = {}) {
143
173
  if (!sessionId || !toolCallId) return result;
144
174
  if (typeof result !== 'string') return result;
145
- if (result.length <= getOffloadThreshold(toolName)) return result;
175
+ if (result.startsWith(TOOL_RESULT_OFFLOAD_PREFIX)) return result;
176
+ const force = options?.force === true;
177
+ if (!force && result.length <= getOffloadThreshold(toolName)) return result;
146
178
  // Keep error surfaces inline so the model can self-correct without an
147
179
  // extra read turn — but only up to the global default. A giant error
148
180
  // (e.g. a megabyte of stack/diff/dump) still offloads so it can't blow up
149
181
  // context; small errors (the overwhelming majority) stay inline.
150
- if (classifyResultKind(result) === 'error'
182
+ if (!force && classifyResultKind(result) === 'error'
151
183
  && result.length <= TOOL_RESULT_OFFLOAD_THRESHOLD_CHARS) return result;
152
184
 
153
185
  // Generate a safe filename — never trust toolCallId as a path component.
@@ -170,6 +202,69 @@ export async function maybeOffloadToolResult(sessionId, toolCallId, toolName, re
170
202
  return `${header}\n\n${preview}${suffix}`;
171
203
  }
172
204
 
205
+ // Apply per-tool persistence first, then enforce the Claude Code message-level
206
+ // budget across the remaining non-Read text results. Selection is
207
+ // largest-first; ties prefer the latest result in the assistant tool batch.
208
+ export async function maybeOffloadToolResultBatch(sessionId, entries, options = {}) {
209
+ const source = Array.isArray(entries) ? entries : [];
210
+ const maxChars = Number(options.maxAggregateChars) > 0
211
+ ? Math.trunc(Number(options.maxAggregateChars))
212
+ : TOOL_RESULT_MESSAGE_MAX_CHARS;
213
+ const applyPerToolLimits = options.applyPerToolLimits !== false;
214
+ const offloadResult = typeof options.offloadResult === 'function'
215
+ ? options.offloadResult
216
+ : maybeOffloadToolResult;
217
+ const states = source.map((entry) => ({ result: entry?.result, error: null }));
218
+
219
+ if (applyPerToolLimits) {
220
+ await Promise.all(source.map(async (entry, index) => {
221
+ try {
222
+ states[index].result = await offloadResult(
223
+ sessionId,
224
+ entry?.toolCallId,
225
+ entry?.toolName,
226
+ entry?.result,
227
+ { force: false },
228
+ );
229
+ } catch (error) {
230
+ states[index].error = error;
231
+ }
232
+ }));
233
+ }
234
+
235
+ const inlineChars = () => states.reduce((total, state, index) => (
236
+ state.error || !isAggregateOffloadEligible(source[index]?.toolName, state.result)
237
+ ? total
238
+ : total + state.result.length
239
+ ), 0);
240
+ let total = inlineChars();
241
+ const attempted = new Set();
242
+ while (total > maxChars) {
243
+ const ranked = rankAggregateOffloadCandidates(source.map((entry, index) => ({
244
+ toolName: entry?.toolName,
245
+ result: states[index].error || attempted.has(index) ? null : states[index].result,
246
+ })));
247
+ if (ranked.length === 0) break;
248
+ const index = ranked[0];
249
+ attempted.add(index);
250
+ const before = states[index].result;
251
+ try {
252
+ states[index].result = await offloadResult(
253
+ sessionId,
254
+ source[index]?.toolCallId,
255
+ source[index]?.toolName,
256
+ before,
257
+ { force: true },
258
+ );
259
+ total += String(states[index].result || '').length - before.length;
260
+ } catch (error) {
261
+ states[index].error = error;
262
+ total -= before.length;
263
+ }
264
+ }
265
+ return states;
266
+ }
267
+
173
268
  // Drop a session's offload sidecars on session close. Unlinks every
174
269
  // tool-results/<sessionId>/*.txt entry, rmdirs the directory, and drops
175
270
  // the sessionId entry from the module-level counter Map so a long-running
@@ -112,18 +112,18 @@ function smartMiddleTruncate(content) {
112
112
  const head = fastLines.slice(0, SMART_BASH_HEAD_LINES).join('\n');
113
113
  const tail = fastLines.slice(-SMART_BASH_TAIL_LINES).join('\n');
114
114
  const middle = fastLines.length - SMART_BASH_HEAD_LINES - SMART_BASH_TAIL_LINES;
115
- return `${head}\n\n... [TRUNCATED — ${middle} lines middle elided; total ${fastLines.length} lines. Rerun with tighter filters for more] ...\n\n${tail}`;
115
+ return `${head}\n\n... [${middle} lines omitted of ${fastLines.length} total — head and tail shown] ...\n\n${tail}`;
116
116
  }
117
117
  const lines = s.split('\n');
118
118
  if (lines.length <= SMART_BASH_MAX_LINES) {
119
119
  const head = s.slice(0, SMART_BASH_MAX_BYTES);
120
- return `${head}\n\n... [TRUNCATED — output exceeded ${Math.round(SMART_BASH_MAX_BYTES / 1024)} KB on a single line] ...`;
120
+ return `${head}\n\n... [output exceeded ${Math.round(SMART_BASH_MAX_BYTES / 1024)} KB on a single line] ...`;
121
121
  }
122
122
  const head = lines.slice(0, SMART_BASH_HEAD_LINES).join('\n');
123
123
  const tail = lines.slice(-SMART_BASH_TAIL_LINES).join('\n');
124
124
  const middle = lines.length - SMART_BASH_HEAD_LINES - SMART_BASH_TAIL_LINES;
125
125
  const totalKb = Math.round(s.length / 1024);
126
- return `${head}\n\n... [TRUNCATED — ${middle} lines middle elided; total ${lines.length} lines / ${totalKb} KB. Rerun with tighter filters for more] ...\n\n${tail}`;
126
+ return `${head}\n\n... [${middle} lines omitted of ${lines.length} total / ${totalKb} KB — head and tail shown] ...\n\n${tail}`;
127
127
  }
128
128
 
129
129
  function _prependDestructiveWarning(command, text) {
@@ -40,12 +40,12 @@ function pushClampNotice(a, msg) {
40
40
 
41
41
  // ripgrep-flavored aliases: models trained on `rg` emit short flags (-A/-B/-C),
42
42
  // long flags (--after-context / --before-context / --context), or snake/camel
43
- // spellings. All fold onto the canonical -A/-B/-C so a caller can write grep
43
+ // spellings. All fold onto the canonical -A/-B/context so a caller can write grep
44
44
  // the way they would write ripgrep on the shell.
45
45
  const GREP_CONTEXT_KEY_GROUPS = [
46
46
  ['-A', ['-A', 'A', 'after', 'after_context', 'afterContext', '--after-context', 'after-context', 'afterLines', 'after_lines']],
47
47
  ['-B', ['-B', 'B', 'before', 'before_context', 'beforeContext', '--before-context', 'before-context', 'beforeLines', 'before_lines']],
48
- ['-C', ['-C', 'C', 'context', 'context_lines', 'contextLines', '--context', 'contextN', 'around', 'surrounding']],
48
+ ['context', ['context', '-C', 'C', 'context_lines', 'contextLines', '--context', 'contextN', 'around', 'surrounding']],
49
49
  ];
50
50
 
51
51
  function grepContextKeyPresent(a, k) {
@@ -81,7 +81,7 @@ export function applyGrepContextLeadPolicy(args) {
81
81
  if (!args || typeof args !== 'object' || Array.isArray(args)) return;
82
82
  // Idempotent guard: the outer builtin guard (validateBuiltinArgs) and the
83
83
  // executor (executeGrepTool) both call this on the SAME args object. The
84
- // first pass folds aliases onto -A/-B/-C and clamps; a second pass would
84
+ // first pass folds aliases onto -A/-B/context and clamps; a second pass would
85
85
  // recompute the same folding for no effect. Skip once applied.
86
86
  if (args._grepContextPolicyApplied) return;
87
87
  for (const [canonical, keys] of GREP_CONTEXT_KEY_GROUPS) {
@@ -375,7 +375,10 @@ export async function executeBashTool(args, workDir, options = {}) {
375
375
  // anywhere in the chain) used to hard-fail via foregroundLongCommandHint
376
376
  // (~22 wasted turns/14d measured). Promote them to a background task —
377
377
  // the exact remedy the deny message pointed at — same as sleep chains.
378
- const _blockedSleep = detectBlockedSleepPattern(command) || detectLongForegroundReason(command);
378
+ // Short (2-29s) leading sleep chains stay SYNC: a 5s settle before a
379
+ // verification probe must not detach produce→verify into an async
380
+ // notification round-trip — blocking a few seconds is strictly cheaper.
381
+ const _blockedSleep = detectBlockedSleepPattern(command, 30) || detectLongForegroundReason(command);
379
382
  if (_blockedSleep) {
380
383
  runInBackground = true;
381
384
  autoAsyncReason = _blockedSleep;
@@ -115,7 +115,7 @@ export const BUILTIN_TOOLS = [
115
115
  name: 'grep',
116
116
  title: 'Mixdog Grep',
117
117
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
118
- description: 'Content search by literal or regex over file/dir scopes.',
118
+ description: 'Content search by literal or regex over file/dir scopes; matches return copy-ready raw source spans.',
119
119
  inputSchema: {
120
120
  type: 'object',
121
121
  properties: {
@@ -143,7 +143,7 @@ export const BUILTIN_TOOLS = [
143
143
  output_mode: { type: 'string', enum: ['content_with_context', 'files_with_matches', 'count'], description: 'content_with_context (default); files_with_matches/count for existence.' },
144
144
  head_limit: { type: 'number', minimum: 0, description: 'Max results. Defaults to 250; 0 = unlimited.' },
145
145
  offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
146
- '-C': { type: 'number', minimum: 0, description: 'Lines before/after each match.' },
146
+ context: { type: 'number', minimum: 0, description: 'Minimum surrounding lines; runtime expands within its call budget. Omit for automatic context; 0 for matches only.' },
147
147
  },
148
148
  anyOf: [
149
149
  { required: ['pattern'] },
@@ -197,7 +197,7 @@ export const BUILTIN_TOOLS = [
197
197
  description: 'Partial path/name words; query[] batches.',
198
198
  },
199
199
  path: { type: 'string', description: 'Base directory.' },
200
- head_limit: { type: 'number', description: 'Max paths. Defaults to 25.' },
200
+ head_limit: { type: 'number', description: 'Max paths across the call. Defaults to 25.' },
201
201
  },
202
202
  required: ['query'],
203
203
  additionalProperties: false,
@@ -207,7 +207,7 @@ export const BUILTIN_TOOLS = [
207
207
  name: 'list',
208
208
  title: 'Mixdog List Directory',
209
209
  annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
210
- description: 'List directory entries; batch independent dirs as path[].',
210
+ description: 'List directory entries (path + type); batch independent dirs as path[].',
211
211
  inputSchema: {
212
212
  type: 'object',
213
213
  properties: {
@@ -94,7 +94,13 @@ function fuzzyScore(query, str) {
94
94
  // order but scattered mid-word, no contiguity/boundary structure) is treated
95
95
  // as noise rather than a real hit. Contiguous substring / basename matches
96
96
  // bypass the floor entirely — an exact hit must never be filtered.
97
- const SUBSEQUENCE_MIN_PER_CHAR = 4;
97
+ // Measured separation for scattered junk vs real hits ("readme" probe):
98
+ // genuine matches score ≥8/char (boundary + contiguity structure), while
99
+ // incidental subsequences on unrelated paths land at 5–6/char — 6.5 splits
100
+ // them. The floor is tested against the RAW subsequence score (see
101
+ // fuzzyRank), never the +40 basename rank bonus, which otherwise vaults
102
+ // weak basename subsequences straight past any floor.
103
+ const SUBSEQUENCE_MIN_PER_CHAR = 6.5;
98
104
 
99
105
  // Separator/case-insensitive normalization used for the "contiguous substring"
100
106
  // strong-match test. Mirrors the query normalization in fuzzyScore so
@@ -163,7 +169,7 @@ export function fuzzyRank(query, items, limit = 0) {
163
169
  const score = prepareFuzzyScore(query);
164
170
  // Floor scales with query length: a scattered subsequence earns ~1 point
165
171
  // per char, while any contiguous run (+5/char) or word-boundary hit
166
- // (+8) pushes a genuine match well past 4/char.
172
+ // (+8) pushes a genuine match well past 6.5/char.
167
173
  const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
168
174
  const scored = [];
169
175
  // Preserve slice's legacy coercion behavior for non-integer public callers;
@@ -185,7 +191,10 @@ export function fuzzyRank(query, items, limit = 0) {
185
191
  // Otherwise it is subsequence-only: keep it only if it clears the
186
192
  // per-char floor. Weak scattered matches (the pgAdmin-style junk that
187
193
  // merely contains the query chars in order) fall below it and drop out.
188
- if (!strong && sc < floor) continue;
194
+ // Compare the RAW subsequence score — the +40 basename rank bonus is
195
+ // for ordering only and must not lift junk over the quality floor.
196
+ const rawSc = Math.max(pathScore ?? -Infinity, baseScore ?? -Infinity);
197
+ if (!strong && rawSc < floor) continue;
189
198
  const entry = { item, score: sc };
190
199
  if (bounded) retainTopRanked(scored, entry, ordinal, limit);
191
200
  else scored.push(entry);
@@ -43,6 +43,28 @@ export function splitGrepCountPrefix(line) {
43
43
  return { path: text.slice(0, idx), pathEnd: idx, count: Number(count) };
44
44
  }
45
45
 
46
+ export function parseGrepContextHeader(line) {
47
+ const match = /^# (.+):(\d+) \[lines (\d+)-(\d+)\]$/.exec(String(line || ''));
48
+ if (!match) return null;
49
+ const matchLine = Number(match[2]);
50
+ const startLine = Number(match[3]);
51
+ const endLine = Number(match[4]);
52
+ if (!Number.isSafeInteger(matchLine)
53
+ || !Number.isSafeInteger(startLine)
54
+ || !Number.isSafeInteger(endLine)
55
+ || startLine < 1
56
+ || endLine < startLine
57
+ || matchLine < startLine
58
+ || matchLine > endLine) return null;
59
+ return {
60
+ path: match[1],
61
+ matchLine,
62
+ startLine,
63
+ endLine,
64
+ sourceLineCount: endLine - startLine + 1,
65
+ };
66
+ }
67
+
46
68
  export function normalizeGrepLine(line, pathOnly = false, outputMode = 'content', filenameOmitted = false) {
47
69
  if (process.platform !== 'win32') return line;
48
70
  // files_with_matches mode: the WHOLE line is a path, so every separator is