mixdog 0.9.93 → 0.9.95

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 (186) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/agent/30-explorer.md +22 -16
  11. package/src/rules/lead/01-general.md +1 -2
  12. package/src/rules/lead/lead-brief.md +4 -5
  13. package/src/rules/lead/lead-tool.md +3 -2
  14. package/src/rules/shared/01-tool.md +28 -29
  15. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +23 -7
  17. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  21. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  22. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  26. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  28. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  29. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  30. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  32. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  33. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  34. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  35. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  38. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  39. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  40. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  41. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  42. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  43. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  44. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  47. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  50. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  52. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +103 -92
  55. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  57. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  58. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +25 -3
  59. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  66. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +20 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  75. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  77. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +546 -23
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +8 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  84. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  86. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +7 -3
  87. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +104 -14
  88. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  89. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  90. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  91. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  92. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +5 -5
  94. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  96. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +151 -18
  97. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  99. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  100. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  101. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  102. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  103. package/src/runtime/channels/backends/discord.mjs +5 -14
  104. package/src/runtime/channels/backends/telegram.mjs +0 -5
  105. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  106. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  107. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  108. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  109. package/src/runtime/media/renditions.mjs +21 -2
  110. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  111. package/src/runtime/memory/tool-defs.mjs +6 -6
  112. package/src/runtime/shared/atomic-file.mjs +53 -0
  113. package/src/runtime/shared/background-tasks.mjs +16 -6
  114. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  115. package/src/runtime/shared/resource-admission.mjs +40 -1
  116. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  117. package/src/runtime/shared/tool-card-model.mjs +6 -2
  118. package/src/runtime/shared/tool-status.mjs +10 -1
  119. package/src/runtime/shared/tool-surface.mjs +5 -0
  120. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  121. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  122. package/src/session-runtime/context-status.mjs +9 -3
  123. package/src/session-runtime/lifecycle-api.mjs +35 -5
  124. package/src/session-runtime/mcp-glue.mjs +11 -6
  125. package/src/session-runtime/provider-models.mjs +94 -43
  126. package/src/session-runtime/provider-usage.mjs +26 -2
  127. package/src/session-runtime/runtime-core.mjs +46 -8
  128. package/src/session-runtime/runtime-tunables.mjs +4 -0
  129. package/src/session-runtime/self-update.mjs +33 -4
  130. package/src/session-runtime/session-lifecycle.mjs +2 -0
  131. package/src/session-runtime/session-text.mjs +2 -1
  132. package/src/session-runtime/session-turn-api.mjs +106 -22
  133. package/src/session-runtime/workflow.mjs +11 -4
  134. package/src/standalone/agent-tool.mjs +4 -4
  135. package/src/standalone/backend-daemon.mjs +570 -0
  136. package/src/standalone/channel-daemon-transport.mjs +141 -1
  137. package/src/standalone/channel-worker.mjs +3 -2
  138. package/src/standalone/engine-daemon-client.mjs +894 -0
  139. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  140. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  141. package/src/standalone/engine-daemon-service.mjs +864 -0
  142. package/src/standalone/engine-daemon-transport.mjs +603 -0
  143. package/src/standalone/explore-tool.mjs +1 -1
  144. package/src/tui/App.jsx +62 -47
  145. package/src/tui/app/app-format.mjs +4 -2
  146. package/src/tui/app/app-view.jsx +5 -0
  147. package/src/tui/app/channel-pickers.mjs +7 -6
  148. package/src/tui/app/core-memory-picker.mjs +4 -4
  149. package/src/tui/app/extension-pickers.mjs +20 -18
  150. package/src/tui/app/maintenance-pickers.mjs +27 -27
  151. package/src/tui/app/onboarding-steps.mjs +23 -18
  152. package/src/tui/app/prompt-submit.mjs +13 -2
  153. package/src/tui/app/route-pickers.mjs +13 -8
  154. package/src/tui/app/settings-picker.mjs +55 -58
  155. package/src/tui/app/slash-dispatch.mjs +32 -28
  156. package/src/tui/app/transcript-window.mjs +19 -0
  157. package/src/tui/app/usage-context-panels.mjs +22 -7
  158. package/src/tui/app/use-mouse-input.mjs +25 -3
  159. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  160. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  161. package/src/tui/app/use-transcript-window.mjs +22 -1
  162. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  163. package/src/tui/components/PromptInput.jsx +10 -0
  164. package/src/tui/components/Spinner.jsx +89 -86
  165. package/src/tui/components/TextEntryPanel.jsx +14 -0
  166. package/src/tui/components/ToolExecution.jsx +2 -2
  167. package/src/tui/dist/index.mjs +1537 -9718
  168. package/src/tui/engine/agent-job-feed.mjs +2 -2
  169. package/src/tui/engine/live-share.mjs +97 -4
  170. package/src/tui/engine/session-api-ext.mjs +23 -1
  171. package/src/tui/engine/session-api.mjs +88 -41
  172. package/src/tui/engine/session-flow.mjs +43 -4
  173. package/src/tui/engine/tool-card-results.mjs +6 -0
  174. package/src/tui/engine/turn.mjs +84 -4
  175. package/src/tui/engine-local-session.mjs +1108 -0
  176. package/src/tui/engine.mjs +16 -1057
  177. package/src/tui/index.jsx +47 -2
  178. package/src/tui/markdown/stream-fence.mjs +1 -1
  179. package/src/tui/spinner-meta.mjs +80 -0
  180. package/src/tui/spinner-verbs.mjs +35 -0
  181. package/src/ui/statusline-segments.mjs +43 -10
  182. package/src/ui/statusline.mjs +10 -1
  183. package/scripts/tmp-cdp-errors.mjs +0 -41
  184. package/scripts/tmp-cdp-inspect.mjs +0 -41
  185. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  186. package/src/standalone/channel-daemon.mjs +0 -226
@@ -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,
@@ -48,7 +48,7 @@ export async function processToolBatch(ctx) {
48
48
  const {
49
49
  calls, messages, tools, cwd, sessionId, sessionRef, signal, opts,
50
50
  iterations, assistantTurnMsg, pending, epoch, startEagerRun,
51
- crossTurnCalls, crossTurnCap, sessionAgent, steeringLadder,
51
+ crossTurnCalls, crossTurnCap, sessionAgent,
52
52
  pushToolResultMessage, throwIfAborted, repeatFailLimit,
53
53
  } = ctx;
54
54
  const executeToolFn = typeof ctx.executeToolFn === 'function' ? ctx.executeToolFn : executeTool;
@@ -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,
@@ -313,6 +323,7 @@ export async function processToolBatch(ctx) {
313
323
  toolEndedAt = Date.now();
314
324
  _resultKind = 'error';
315
325
  } else {
326
+ await opts.beforeToolExecution?.();
316
327
  result = await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations, deferShellCwdCommit: true });
317
328
  toolEndedAt = Date.now();
318
329
  // Boundary: tool-return string convention → structural kind.
@@ -516,37 +527,62 @@ export async function processToolBatch(ctx) {
516
527
  if (sessionId && _isShellTool(call.name)) {
517
528
  clearScopedToolsForSession(sessionId);
518
529
  }
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;
530
+ _batchCompleted.push({
531
+ call,
532
+ result,
533
+ toolStartedAt,
534
+ toolEndedAt,
535
+ toolKind,
536
+ resultKind: _resultKind,
537
+ executeOk: _executeOk,
538
+ readCacheHit: _readCacheHit,
539
+ scopedCacheHit: _scopedCacheHit,
540
+ crossTurnSig: _ctSig,
541
+ mutationEpoch: epoch.mutation,
542
+ nativeToolSearch: null,
543
+ postError: null,
544
+ });
545
+ // Soft-cancel after each tool: if close landed during execution,
546
+ // discard the rest of the batch and skip the next provider.send.
547
+ throwIfAborted();
548
+ }
549
+ for (const completed of _batchCompleted) {
540
550
  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);
551
+ completed.nativeToolSearch = parseNativeToolSearchPayload(completed.call.name, completed.result);
552
+ if (completed.nativeToolSearch?.summary) completed.result = completed.nativeToolSearch.summary;
553
+ } catch (error) {
554
+ completed.postError = error;
555
+ }
556
+ }
557
+ let _offloadStates;
558
+ try {
559
+ _offloadStates = await maybeOffloadToolResultBatch(
560
+ sessionId,
561
+ _batchCompleted.map((completed) => ({
562
+ toolCallId: completed.call.id,
563
+ toolName: _stripMcpPrefix(completed.call.name),
564
+ result: completed.postError ? null : completed.result,
565
+ })),
566
+ );
567
+ } catch (error) {
568
+ _offloadStates = _batchCompleted.map(() => ({ result: null, error }));
569
+ }
570
+ // Assistant-message pipeline: offload → compress → trace → cache →
571
+ // push. The cache and transcript therefore receive the same body.
572
+ for (let completedIndex = 0; completedIndex < _batchCompleted.length; completedIndex += 1) {
573
+ const completed = _batchCompleted[completedIndex];
574
+ const {
575
+ call, toolStartedAt, toolEndedAt, toolKind,
576
+ executeOk: _executeOk, resultKind: _resultKind,
577
+ readCacheHit: _readCacheHit, scopedCacheHit: _scopedCacheHit,
578
+ } = completed;
579
+ let _ctSig = completed.crossTurnSig;
580
+ let result = completed.result;
581
+ const _nativeToolSearch = completed.nativeToolSearch;
582
+ try {
583
+ if (completed.postError) throw completed.postError;
584
+ if (_offloadStates[completedIndex]?.error) throw _offloadStates[completedIndex].error;
585
+ result = _offloadStates[completedIndex]?.result;
550
586
  result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
551
587
  traceAgentTool({
552
588
  sessionId,
@@ -561,55 +597,44 @@ export async function processToolBatch(ctx) {
561
597
  resultText: result,
562
598
  cwd,
563
599
  });
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') {
600
+ // Deferred writes that predate a later mutation are skipped;
601
+ // immediate writes used to be invalidated by that mutation.
602
+ const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
603
+ ? sessionRef._scopedCacheOutcomeByCallId : null;
604
+ if (sessionId && _executeOk && _resultKind === 'normal' && completed.mutationEpoch === epoch.mutation) {
572
605
  if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
573
- const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
574
- ? sessionRef._scopedCacheOutcomeByCallId : null;
575
606
  const _outcome = _outcomeMap?.get(call.id);
576
607
  setScopedToolCached({
577
608
  sessionId,
578
- toolName: _toolBare,
609
+ toolName: _stripMcpPrefix(call.name),
579
610
  args: call.arguments,
580
611
  cwd,
581
612
  content: result,
582
613
  toolUseId: call.id,
583
614
  complete: _outcome ? _outcome.complete : true,
584
615
  });
585
- _outcomeMap?.delete(call.id);
586
616
  }
587
617
  if (_readCacheHit === null && _isReadTool(call.name)) {
588
- // Pass tool_use id so future cache-hits can reference the body's location in history.
589
618
  setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
590
619
  }
591
620
  }
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.
621
+ // A successful scoped lookup from before a later mutation is
622
+ // intentionally not cached, but its per-call completeness
623
+ // record still has to be reclaimed.
624
+ if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
625
+ _outcomeMap?.delete(call.id);
626
+ }
598
627
  const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
599
628
  ? takeApplyPatchUiDiff(call.id)
600
629
  : null;
601
- pushToolResultMessage({
630
+ _stageToolResultMessage({
602
631
  role: 'tool',
603
632
  content: result,
604
633
  toolCallId: call.id,
605
634
  toolKind: _resultKind,
606
635
  ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
607
- ...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
636
+ ...(_applyPatchUiDiff !== null ? { uiDiff: _applyPatchUiDiff } : {}),
608
637
  });
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
638
  if (_executeOk) {
614
639
  const _isEager = isEagerDispatchable(call.name, tools);
615
640
  if (isToolCallDedupEligible(call.name, tools)) {
@@ -621,30 +646,19 @@ export async function processToolBatch(ctx) {
621
646
  crossTurnCalls.delete(_oldest);
622
647
  }
623
648
  }
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
- }
649
+ } else if (!_isEager && isEditProgressTool(call.name, false)) {
650
+ crossTurnCalls.clear();
651
+ editCount += 1;
633
652
  }
634
653
  }
635
654
  } 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
655
  if (_executeOk && !isEagerDispatchable(call.name, tools) && isEditProgressTool(call.name, false)) {
642
656
  crossTurnCalls.clear();
643
657
  editCount += 1;
644
658
  }
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).
659
+ if (sessionRef?._scopedCacheOutcomeByCallId instanceof Map && call?.id) {
660
+ sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
661
+ }
648
662
  if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
649
663
  const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
650
664
  traceAgentToolFailure({
@@ -660,24 +674,25 @@ export async function processToolBatch(ctx) {
660
674
  resultText: _postMsg,
661
675
  resultKind: 'error',
662
676
  });
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({
677
+ _stageToolResultMessage({
671
678
  role: 'tool',
672
679
  content: _postMsg,
673
680
  toolCallId: call.id,
674
681
  toolKind: 'error',
675
682
  });
676
683
  }
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
684
  throwIfAborted();
680
685
  }
686
+ // Early dedup/guard skips and deferred execution results share this
687
+ // ordered flush so provider tool_result blocks always follow the
688
+ // assistant's original tool_use order.
689
+ for (const call of calls) {
690
+ const message = _batchToolResultByCallId.get(call?.id);
691
+ if (message) pushToolResultMessage(message);
692
+ }
693
+ for (const message of _batchToolResultsWithoutId) {
694
+ pushToolResultMessage(message);
695
+ }
681
696
  // Flush the per-batch newMessages channel. All tool_results for this
682
697
  // assistant turn are now pushed; appending the injected role:'user'
683
698
  // messages here (AFTER the last tool_result, BEFORE the next provider
@@ -716,10 +731,6 @@ export async function processToolBatch(ctx) {
716
731
  } catch { /* best-effort: PostToolBatch hook must never break the loop */ }
717
732
  }
718
733
  }
719
- // Completion-first steering hints (missed-parallelism / all-read-only /
720
- // serial-rewording). At most ONE hint per turn. The ladder controller
721
- // owns the cumulative counters and streaks.
722
- steeringLadder.emitPostBatchSteering(calls, false);
723
734
  // Mid-turn steering is drained at the next loop's pre-send point,
724
735
  // AFTER any auto-compact pass. Draining here would put the steering
725
736
  // user turn after the fresh tool results before compaction runs; then
@@ -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 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
@@ -144,9 +144,8 @@ export const PROVIDER_SSE_IDLE_TIMEOUT_MS = resolveTimeoutMs(
144
144
  // to 300s silently collapsed it to 135s, which deterministically beheaded
145
145
  // live long-thinking turns (regex-chess / circuit-fibsqrt: ttft ~1.5s, then
146
146
  // killed at exactly 135s of silent thinking, 4 attempts ≈ 552s per turn).
147
- // Reference parity: Codex aborts only after a 300s single-gap silence
148
- // (DEFAULT_STREAM_IDLE_TIMEOUT_MS); Claude Code ships its watchdog OFF by
149
- // default and falls back to non-streaming when it does abort. The default is
147
+ // Reference behavior: abort only after a 300s single-gap silence, and fall
148
+ // back to non-streaming instead of killing the turn. The default is
150
149
  // therefore a FIXED 300s, decoupled from the warn math. The only remaining
151
150
  // coupling is the ordering guarantee: cap at (abort − tick) so the provider
152
151
  // layer — which can retry or fall back non-streaming — always fires strictly
@@ -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) {
@@ -726,7 +726,19 @@ function guardFind(a) {
726
726
  if (!queryOk) {
727
727
  return `Error: find requires non-empty string (or string[]) "query" (got ${describeType(a.query)})`;
728
728
  }
729
+ if (hasOwn(a, 'path')) {
730
+ // Absorb: a JSON-stringified array ('["apps/desktop"]') or a
731
+ // single-entry batch (['apps/desktop']). find takes ONE base
732
+ // directory, and a lone entry IS that directory — no ambiguity.
733
+ const coerced = coerceShapeFlex(a.path);
734
+ a.path = (Array.isArray(coerced) && coerced.length === 1 && isString(coerced[0]))
735
+ ? coerced[0]
736
+ : coerced;
737
+ }
729
738
  if (hasOwn(a, 'path') && !isString(a.path)) {
739
+ if (Array.isArray(a.path)) {
740
+ return `Error: find arg "path" must be a single base directory (got ${a.path.length}) — run find once per base, or widen "query".`;
741
+ }
730
742
  return `Error: find arg "path" must be a string (got ${describeType(a.path)})`;
731
743
  }
732
744
  if (hasOwn(a, 'head_limit') && a.head_limit !== undefined && a.head_limit !== null) {
@@ -832,6 +844,15 @@ const GUARDS = {
832
844
  code_graph: guardCodeGraph,
833
845
  };
834
846
 
847
+ // A bridge/provider can materialize an optional schema field as an explicit
848
+ // `undefined`. JSON cannot carry undefined, so it means "not sent" — drop the
849
+ // key instead of failing the call on a field the caller never set.
850
+ function dropUndefinedArgs(args) {
851
+ for (const key of Object.keys(args)) {
852
+ if (args[key] === undefined) delete args[key];
853
+ }
854
+ }
855
+
835
856
  export function validateBuiltinArgs(toolName, args) {
836
857
  const guard = GUARDS[toolName];
837
858
  if (!guard) return null;
@@ -841,6 +862,7 @@ export function validateBuiltinArgs(toolName, args) {
841
862
  if (typeof args !== 'object' || Array.isArray(args)) {
842
863
  return `Error: ${toolName} arguments must be an object (got ${describeType(args)})`;
843
864
  }
865
+ dropUndefinedArgs(args);
844
866
  if (toolName === 'grep') applyGrepContextLeadPolicy(args);
845
867
  try {
846
868
  return guard(args) || null;
@@ -256,7 +256,7 @@ export async function executeBashTool(args, workDir, options = {}) {
256
256
  // decode + rm token guard; calling it here applies the same allowlist
257
257
  // to both persistent and stateless paths.
258
258
  const _rawCmd = String(args && args.command != null ? args.command : '');
259
- // Codex-parity: `apply_patch` typed into the shell (heredoc/argument/bare
259
+ // `apply_patch` typed into the shell (heredoc/argument/bare
260
260
  // patch forms) routes to the internal patch engine instead of failing as
261
261
  // an unknown binary. Runs BEFORE the exec-policy scan so patch BODY lines
262
262
  // (e.g. `+ rm -rf …`) are never misread as shell commands. Dynamic import
@@ -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;
@@ -521,6 +524,9 @@ export async function executeBashTool(args, workDir, options = {}) {
521
524
  // Per-terminal session stamp: the dispatching terminal's
522
525
  // claude.exe pid (server-main threads callerSession.clientHostPid).
523
526
  clientHostPid: options?.clientHostPid,
527
+ // Dispatching session: hosts that pool many sessions in one
528
+ // process (desktop) scope the job to its own pane with this.
529
+ ownerSessionId: options?.callerSessionId || options?.sessionId || null,
524
530
  ...(options?.shellJobRuntime || {}),
525
531
  });
526
532
  if (job && job.error) {
@@ -631,6 +637,8 @@ export async function executeBashTool(args, workDir, options = {}) {
631
637
  // Threaded so an auto-backgrounded foreground job is stamped with
632
638
  // the dispatching terminal's claude.exe pid (per-terminal scope).
633
639
  clientHostPid: options?.clientHostPid,
640
+ // …and with the dispatching session, for per-pane scoping.
641
+ ownerSessionId: options?.callerSessionId || options?.sessionId || null,
634
642
  // MCP live-progress reporter (null unless the client subscribed via
635
643
  // callTool onprogress). execShellCommand emits throttled "running
636
644
  // Ns" frames while the foreground command runs.