mixdog 0.9.41 → 0.9.44

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 (73) hide show
  1. package/package.json +3 -2
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/ansi-color-capability-test.mjs +90 -0
  4. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  5. package/scripts/arg-guard-test.mjs +16 -0
  6. package/scripts/compact-pressure-test.mjs +256 -1
  7. package/scripts/generate-runtime-manifest.mjs +39 -22
  8. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  9. package/scripts/internal-comms-bench.mjs +0 -1
  10. package/scripts/internal-comms-smoke.mjs +38 -39
  11. package/scripts/internal-tools-normalization-test.mjs +52 -0
  12. package/scripts/maintenance-default-routes-test.mjs +164 -0
  13. package/scripts/max-output-recovery-test.mjs +49 -0
  14. package/scripts/mcp-client-normalization-test.mjs +45 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  16. package/scripts/provider-toolcall-test.mjs +23 -0
  17. package/scripts/result-classification-test.mjs +75 -0
  18. package/scripts/routing-corpus.mjs +1 -1
  19. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  20. package/scripts/smoke.mjs +1 -1
  21. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  22. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  23. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  24. package/scripts/tui-transcript-perf-test.mjs +56 -1
  25. package/src/agents/reviewer/AGENT.md +5 -3
  26. package/src/rules/lead/lead-brief.md +5 -4
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  28. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  29. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  34. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  35. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  39. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  44. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  45. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  46. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  48. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  49. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  50. package/src/runtime/memory/tool-defs.mjs +4 -3
  51. package/src/runtime/shared/tool-surface.mjs +1 -2
  52. package/src/session-runtime/context-status.mjs +8 -2
  53. package/src/standalone/agent-tool/render.mjs +2 -0
  54. package/src/standalone/agent-tool.mjs +267 -72
  55. package/src/standalone/explore-tool.mjs +17 -16
  56. package/src/tui/app/provider-setup-picker.mjs +1 -0
  57. package/src/tui/app/use-transcript-window.mjs +5 -1
  58. package/src/tui/components/StatusLine.jsx +11 -32
  59. package/src/tui/dist/index.mjs +257 -106
  60. package/src/tui/engine/session-api.mjs +3 -0
  61. package/src/tui/engine/session-flow.mjs +19 -8
  62. package/src/tui/engine/turn.mjs +24 -2
  63. package/src/tui/engine.mjs +0 -1
  64. package/src/tui/index.jsx +3 -2
  65. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  66. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  67. package/src/ui/ansi.mjs +85 -5
  68. package/src/ui/statusline-agents.mjs +0 -8
  69. package/src/ui/statusline-format.mjs +7 -7
  70. package/src/ui/statusline.mjs +2 -4
  71. package/src/workflows/default/WORKFLOW.md +29 -20
  72. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  73. package/src/workflows/bench/WORKFLOW.md +0 -60
@@ -1,6 +1,7 @@
1
1
  import { isOffloadedToolResultText } from './tool-result-offload.mjs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { isAgentOwner } from '../agent-owner.mjs';
4
+ import { contentImageDescriptors, contentToText } from '../providers/media-normalization.mjs';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Conservative, Unicode-aware token estimator.
@@ -26,6 +27,8 @@ function readSafetyMultiplier() {
26
27
  return 1.1;
27
28
  }
28
29
  const TOKEN_ESTIMATE_SAFETY_MULTIPLIER = readSafetyMultiplier();
30
+ export const IMAGE_VISUAL_TOKEN_ALLOWANCE = 4_096;
31
+ const IMAGE_TILE_TOKEN_ALLOWANCE = 512;
29
32
 
30
33
  // Per-code-point token-cost weight. Tuned to overcount, not match exactly.
31
34
  function codePointTokenWeight(cp) {
@@ -72,12 +75,59 @@ export function estimateTokens(text) {
72
75
  if (s.length === 0) return 0;
73
76
  let weighted = 0;
74
77
  for (const ch of s) weighted += codePointTokenWeight(ch.codePointAt(0));
78
+ // Encoded blobs, minified JSON and generated identifiers do not get the
79
+ // word/whitespace merges that make prose approach chars/4. Long printable
80
+ // ASCII runs are commonly 0.5-0.8 tokens/byte; retain a conservative floor
81
+ // for those runs without penalizing ordinary spaced prose.
82
+ let denseAsciiFloor = 0;
83
+ for (const match of s.matchAll(/[\x21-\x7e]{16,}/g)) {
84
+ denseAsciiFloor += match[0].length * 0.75;
85
+ }
86
+ const encodedWords = s.match(/\b(?=[A-Za-z0-9]{8,}\b)(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]+\b/g) || [];
87
+ if (encodedWords.length >= 3) {
88
+ // Encoded/generated identifiers are often wrapped at short columns or
89
+ // separated by spaces. Their individual runs can stay below the long-
90
+ // run threshold while still receiving almost no prose-style BPE merges.
91
+ const encodedChars = encodedWords.reduce((sum, word) => sum + word.length, 0);
92
+ denseAsciiFloor = Math.max(
93
+ denseAsciiFloor,
94
+ (encodedChars * 0.75) + ((s.length - encodedChars) * 0.25),
95
+ );
96
+ }
97
+ const lines = s.split(/\r?\n/).filter(line => line.trim());
98
+ const nonWhitespace = s.match(/\S/g)?.length || 0;
99
+ const structural = s.match(/[\[\]{}":,=<>|\\]/g)?.length || 0;
100
+ const jsonLikeLines = lines.filter(line => /^\s*[\[{].*[\]}],?\s*$/.test(line)).length;
101
+ if (lines.length >= 3 && nonWhitespace > 0
102
+ && (jsonLikeLines >= Math.ceil(lines.length / 2) || structural / nonWhitespace >= 0.12)) {
103
+ // JSONL, compact tables and generated line protocols can consist
104
+ // entirely of short runs while still tokenizing like minified data.
105
+ denseAsciiFloor = Math.max(denseAsciiFloor, (nonWhitespace * 0.7) + ((s.length - nonWhitespace) * 0.25));
106
+ }
75
107
  const asciiFloor = s.length / 4; // never below the legacy chars/4 lower bound
76
- return Math.ceil(Math.max(weighted, asciiFloor) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
108
+ return Math.ceil(Math.max(weighted, asciiFloor, denseAsciiFloor) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
109
+ }
110
+
111
+ function nativeBlocksEstimateText(value) {
112
+ const list = Array.isArray(value) ? value : [value];
113
+ return list.map((block) => {
114
+ const images = contentImageDescriptors(block);
115
+ if (images.length) {
116
+ return JSON.stringify(images.map(({ width, height, detail }) => ({
117
+ type: 'image', width, height, detail,
118
+ })));
119
+ }
120
+ try { return JSON.stringify(block); }
121
+ catch { return String(block ?? ''); }
122
+ }).join('\n');
77
123
  }
78
124
  export function messageEstimateText(m) {
79
125
  if (!m || typeof m !== 'object') return '';
80
- let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '');
126
+ // Multimodal image payloads remain on the live message for provider sends,
127
+ // but their base64/data-url JSON is not text and must not dominate local
128
+ // context estimates. Use the same media-aware text projection for every
129
+ // estimate consumer (live gauge, compaction fallback, and summaries).
130
+ let text = contentToText(m.content, '');
81
131
  if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
82
132
  try { text += `\n${JSON.stringify(m.toolCalls)}`; }
83
133
  catch { text += `\n[${m.toolCalls.length} tool calls]`; }
@@ -86,14 +136,47 @@ export function messageEstimateText(m) {
86
136
  // signature / redacted data) and are re-sent on tool-continuation turns, so
87
137
  // they consume real input tokens. Count them or trim/compact undercounts.
88
138
  if (m.role === 'assistant' && Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length) {
89
- try { text += `\n${JSON.stringify(m.thinkingBlocks)}`; }
90
- catch { text += `\n[${m.thinkingBlocks.length} thinking blocks]`; }
139
+ text += `\n${nativeBlocksEstimateText(m.thinkingBlocks)}`;
140
+ }
141
+ // Some provider adapters replay their native assistant representation
142
+ // instead of content/toolCalls. Project it through the media normalizer so
143
+ // text/tool metadata and opaque reasoning are counted without base64 image
144
+ // bytes dominating the estimate.
145
+ if (m.role === 'assistant' && Array.isArray(m.assistantBlocks) && m.assistantBlocks.length) {
146
+ text += `\n${nativeBlocksEstimateText(m.assistantBlocks)}`;
147
+ }
148
+ if (m.role === 'assistant' && Array.isArray(m.reasoningItems) && m.reasoningItems.length) {
149
+ text += `\n${nativeBlocksEstimateText(m.reasoningItems)}`;
91
150
  }
92
151
  if (m.role === 'tool' && m.toolCallId) text += `\n${m.toolCallId}`;
93
152
  return text;
94
153
  }
154
+ function imageDescriptorAllowance(descriptor) {
155
+ if (descriptor.width && descriptor.height) {
156
+ const tiles = Math.ceil(descriptor.width / 512) * Math.ceil(descriptor.height / 512);
157
+ // Caller-supplied dimensions/detail are not uniformly preserved by all
158
+ // provider normalizers, so they may raise the allowance for a known
159
+ // multi-tile image but can never lower the conservative unknown-image
160
+ // fallback. This avoids trusting metadata the provider never sees.
161
+ return Math.max(IMAGE_VISUAL_TOKEN_ALLOWANCE, IMAGE_TILE_TOKEN_ALLOWANCE * (tiles + 1));
162
+ }
163
+ // Unknown-size auto/high images may be tiled by the provider. A single
164
+ // 1k allowance is optimistic for common screenshots and documents.
165
+ return IMAGE_VISUAL_TOKEN_ALLOWANCE;
166
+ }
167
+ function messageImageDescriptors(m) {
168
+ if (!m || typeof m !== 'object') return [];
169
+ return [
170
+ ...contentImageDescriptors(m.content),
171
+ ...(m.role === 'assistant' ? contentImageDescriptors(m.assistantBlocks) : []),
172
+ ];
173
+ }
174
+ function messageImageAllowance(m) {
175
+ if (!m || typeof m !== 'object') return 0;
176
+ return messageImageDescriptors(m).reduce((sum, descriptor) => sum + imageDescriptorAllowance(descriptor), 0);
177
+ }
95
178
  export function estimateMessageTokens(m) {
96
- return estimateTokens(messageEstimateText(m)) + 4;
179
+ return estimateTokens(messageEstimateText(m)) + messageImageAllowance(m) + 4;
97
180
  }
98
181
  export function estimateMessagesTokens(messages) {
99
182
  return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
@@ -112,28 +195,11 @@ const contextMessageMemo = new WeakMap();
112
195
  const contextTranscriptMemo = new WeakMap();
113
196
 
114
197
  function contextValueFingerprint(value) {
115
- if (typeof value === 'string') return { value, entries: null };
116
- if (Array.isArray(value)) return {
117
- value,
118
- entries: value.map(contextBlockFingerprint),
119
- };
120
- return value && typeof value === 'object'
121
- ? { value, entries: [contextBlockFingerprint(value)] }
122
- : { value, entries: null };
123
- }
124
-
125
- function contextBlockFingerprint(block) {
126
- if (!block || typeof block !== 'object') return { value: block };
127
- const fn = block.function;
128
198
  return {
129
- value: block,
130
- text: typeof block.text === 'string' ? block.text : null,
131
- content: typeof block.content === 'string' ? block.content : null,
132
- args: typeof block.args === 'string' ? block.args : null,
133
- arguments: block.arguments,
134
- input: typeof block.input === 'string' ? block.input : null,
135
- function: fn,
136
- functionArguments: typeof fn?.arguments === 'string' ? fn.arguments : null,
199
+ value,
200
+ snapshot: typeof value === 'string'
201
+ ? value
202
+ : `${nativeBlocksEstimateText(value)}\0${JSON.stringify(contentImageDescriptors(value))}`,
137
203
  };
138
204
  }
139
205
 
@@ -145,6 +211,7 @@ function contextMessageFingerprint(message) {
145
211
  toolCalls: contextValueFingerprint(null),
146
212
  thinkingBlocks: contextValueFingerprint(null),
147
213
  assistantBlocks: contextValueFingerprint(null),
214
+ reasoningItems: contextValueFingerprint(null),
148
215
  toolCallId: null,
149
216
  };
150
217
  }
@@ -154,30 +221,13 @@ function contextMessageFingerprint(message) {
154
221
  toolCalls: contextValueFingerprint(message.toolCalls),
155
222
  thinkingBlocks: contextValueFingerprint(message.thinkingBlocks),
156
223
  assistantBlocks: contextValueFingerprint(message.assistantBlocks),
224
+ reasoningItems: contextValueFingerprint(message.reasoningItems),
157
225
  toolCallId: message?.toolCallId || null,
158
226
  };
159
227
  }
160
228
 
161
229
  function sameContextValueFingerprint(a, b) {
162
- if (!a || !b || a.value !== b.value) return false;
163
- if (a.entries === null || b.entries === null) return a.entries === b.entries;
164
- if (a.entries.length !== b.entries.length) return false;
165
- for (let index = 0; index < a.entries.length; index += 1) {
166
- if (!sameContextBlockFingerprint(a.entries[index], b.entries[index])) return false;
167
- }
168
- return true;
169
- }
170
-
171
- function sameContextBlockFingerprint(a, b) {
172
- return !!a && !!b
173
- && a.value === b.value
174
- && a.text === b.text
175
- && a.content === b.content
176
- && a.args === b.args
177
- && Object.is(a.arguments, b.arguments)
178
- && a.input === b.input
179
- && a.function === b.function
180
- && a.functionArguments === b.functionArguments;
230
+ return !!a && !!b && a.value === b.value && a.snapshot === b.snapshot;
181
231
  }
182
232
 
183
233
  function sameContextMessageFingerprint(a, b) {
@@ -186,6 +236,7 @@ function sameContextMessageFingerprint(a, b) {
186
236
  && sameContextValueFingerprint(a.toolCalls, b.toolCalls)
187
237
  && sameContextValueFingerprint(a.thinkingBlocks, b.thinkingBlocks)
188
238
  && sameContextValueFingerprint(a.assistantBlocks, b.assistantBlocks)
239
+ && sameContextValueFingerprint(a.reasoningItems, b.reasoningItems)
189
240
  && a.toolCallId === b.toolCallId;
190
241
  }
191
242
 
@@ -197,7 +248,7 @@ function contextMessageContribution(message) {
197
248
  }
198
249
  const role = ['system', 'user', 'assistant', 'tool'].includes(fingerprint.role) ? fingerprint.role : 'other';
199
250
  const text = messageEstimateText(message);
200
- const tokens = estimateTokens(text) + 4;
251
+ const tokens = estimateTokens(text) + messageImageAllowance(message) + 4;
201
252
  const contribution = {
202
253
  role,
203
254
  tokens,
@@ -511,7 +562,7 @@ export function summarizeContextMessages(messages) {
511
562
  if (!Array.isArray(messages)) return contextSummaryResult(emptyContextSummaryState(), 0);
512
563
  let cached = contextTranscriptMemo.get(messages);
513
564
  if (!cached || messages.length < cached.count) {
514
- cached = { count: 0, contributions: [], state: emptyContextSummaryState() };
565
+ cached = { count: 0, contributions: [], state: emptyContextSummaryState(), revision: 0 };
515
566
  contextTranscriptMemo.set(messages, cached);
516
567
  }
517
568
  for (let index = 0; index < messages.length; index += 1) {
@@ -521,12 +572,42 @@ export function summarizeContextMessages(messages) {
521
572
  if (previous) applyContextMessageContribution(cached.state, previous, -1);
522
573
  cached.contributions[index] = contribution;
523
574
  applyContextMessageContribution(cached.state, contribution, 1);
575
+ cached.revision += 1;
524
576
  }
525
577
  cached.contributions.length = messages.length;
526
578
  cached.count = messages.length;
527
579
  return contextSummaryResult(cached.state, messages.length);
528
580
  }
529
581
 
582
+ // A stable warm-cache generation for consumers that cache a derived view of
583
+ // the whole transcript. summarizeContextMessages() must run first so mutations
584
+ // to any entry, not merely the tail, advance the generation.
585
+ export function contextMessagesRevision(messages) {
586
+ if (!Array.isArray(messages)) return 0;
587
+ summarizeContextMessages(messages);
588
+ return contextTranscriptMemo.get(messages)?.revision || 0;
589
+ }
590
+
591
+ // Hash only estimator/provider-visible projections. In particular, images
592
+ // contribute their visual count but never their raw data-url/base64 bytes.
593
+ export function contextMessagesSignature(messages, count = messages?.length) {
594
+ const list = Array.isArray(messages) ? messages : [];
595
+ const end = Math.max(0, Math.min(list.length, Number.isInteger(count) ? count : list.length));
596
+ const hash = createHash('sha256');
597
+ for (let index = 0; index < end; index += 1) {
598
+ const message = list[index];
599
+ hash.update(JSON.stringify([
600
+ message?.role || '',
601
+ message?.toolCallId || '',
602
+ messageEstimateText(message),
603
+ messageImageAllowance(message),
604
+ messageImageDescriptors(message),
605
+ ]));
606
+ hash.update('\0');
607
+ }
608
+ return hash.digest('hex');
609
+ }
610
+
530
611
  // Per-request overhead the provider injects that never appears in the
531
612
  // `messages` array: function-calling preamble + system-prompt framing the
532
613
  // provider wraps around the request. The chars/4 message estimate misses all
@@ -535,35 +616,13 @@ const REQUEST_OVERHEAD_TOKENS = 512;
535
616
  const toolSchemaTokenMemo = new WeakMap();
536
617
  const requestReserveTokenMemo = new WeakMap();
537
618
 
538
- function sameToolArrayEntries(cached, tools) {
539
- if (!cached || cached.entries.length !== tools.length) return false;
540
- for (let index = 0; index < tools.length; index += 1) {
541
- const entry = cached.entries[index];
542
- const tool = tools[index];
543
- if (entry.tool !== tool
544
- || entry.name !== tool?.name
545
- || entry.description !== tool?.description
546
- || entry.inputSchema !== tool?.inputSchema
547
- || entry.input_schema !== tool?.input_schema
548
- || entry.parameters !== tool?.parameters
549
- || entry.schema !== tool?.schema) return false;
550
- }
551
- return true;
619
+ function serializeToolSchemas(tools) {
620
+ try { return JSON.stringify(Array.isArray(tools) ? tools : []); }
621
+ catch { return (Array.isArray(tools) ? tools : []).map(t => String(t?.name ?? '')).join(''); }
552
622
  }
553
623
 
554
- function cacheToolArrayValue(tools, value) {
555
- return {
556
- entries: tools.map((tool) => ({
557
- tool,
558
- name: tool?.name,
559
- description: tool?.description,
560
- inputSchema: tool?.inputSchema,
561
- input_schema: tool?.input_schema,
562
- parameters: tool?.parameters,
563
- schema: tool?.schema,
564
- })),
565
- value,
566
- };
624
+ export function toolSchemaSignature(tools) {
625
+ return createHash('sha256').update(serializeToolSchemas(tools)).digest('hex');
567
626
  }
568
627
 
569
628
  /**
@@ -576,13 +635,12 @@ function cacheToolArrayValue(tools, value) {
576
635
  */
577
636
  export function estimateToolSchemaTokens(tools) {
578
637
  if (!Array.isArray(tools) || tools.length === 0) return 0;
638
+ const signature = toolSchemaSignature(tools);
579
639
  const cached = toolSchemaTokenMemo.get(tools);
580
- if (sameToolArrayEntries(cached, tools)) return cached.value;
581
- let text = '';
582
- try { text = JSON.stringify(tools); }
583
- catch { text = tools.map(t => String(t?.name ?? '')).join(''); }
640
+ if (cached?.signature === signature) return cached.value;
641
+ const text = serializeToolSchemas(tools);
584
642
  const tokens = estimateTokens(text);
585
- toolSchemaTokenMemo.set(tools, cacheToolArrayValue(tools, tokens));
643
+ toolSchemaTokenMemo.set(tools, { signature, value: tokens });
586
644
  return tokens;
587
645
  }
588
646
 
@@ -594,10 +652,11 @@ export function estimateToolSchemaTokens(tools) {
594
652
  */
595
653
  export function estimateRequestReserveTokens(tools) {
596
654
  if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
655
+ const signature = toolSchemaSignature(tools);
597
656
  const cached = requestReserveTokenMemo.get(tools);
598
- if (sameToolArrayEntries(cached, tools)) return cached.value;
657
+ if (cached?.signature === signature) return cached.value;
599
658
  const reserve = estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
600
- requestReserveTokenMemo.set(tools, cacheToolArrayValue(tools, reserve));
659
+ requestReserveTokenMemo.set(tools, { signature, value: reserve });
601
660
  return reserve;
602
661
  }
603
662
 
@@ -3,9 +3,11 @@
3
3
  // runRecallFastTrackCompact stays in the loop (it drives the recall pipeline
4
4
  // against live session state).
5
5
  import {
6
+ contextMessagesSignature,
6
7
  estimateMessagesTokens,
7
8
  estimateRequestReserveTokens,
8
9
  resolveSessionCompactPolicy,
10
+ toolSchemaSignature,
9
11
  } from '../context-utils.mjs';
10
12
  import {
11
13
  compactTypeIsRecallFastTrack,
@@ -132,6 +134,7 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
132
134
  reserveTokens: requestReserve + configuredReserve,
133
135
  requestReserveTokens: requestReserve,
134
136
  configuredReserveTokens: configuredReserve,
137
+ toolSchemaSignature: toolSchemaSignature(tools),
135
138
  };
136
139
  }
137
140
  /** Transcript + request reserve fallback used until an aligned provider baseline exists. */
@@ -159,13 +162,20 @@ function providerPressureTokens(sessionRef, usage) {
159
162
  * covers. Later pressure checks add estimates only for messages after this
160
163
  * baseline, matching Claude Code's actual-usage-plus-growth accounting.
161
164
  */
162
- export function recordProviderContextBaseline(sessionRef, messages, usage, { boundary = 'complete' } = {}) {
165
+ export function recordProviderContextBaseline(sessionRef, messages, usage, {
166
+ boundary = 'complete',
167
+ sendTools = sessionRef?.tools,
168
+ } = {}) {
163
169
  if (!sessionRef || !Array.isArray(messages)) return false;
164
170
  const tokens = providerPressureTokens(sessionRef, usage);
165
171
  if (!tokens) return false;
166
172
  sessionRef.contextPressureBaselineTokens = tokens;
167
173
  sessionRef.contextPressureBaselineOutputTokens = Math.max(0, Math.round(Number(usage?.outputTokens) || 0));
168
174
  sessionRef.contextPressureBaselineMessageCount = messages.length;
175
+ sessionRef.contextPressureBaselinePrefixSignature = contextMessagesSignature(messages);
176
+ sessionRef.contextPressureBaselineProvider = sessionRef.provider || null;
177
+ sessionRef.contextPressureBaselineModel = sessionRef.model || null;
178
+ sessionRef.contextPressureBaselineToolSignature = toolSchemaSignature(sendTools);
169
179
  // provider_send usage arrives before the response's assistant message is
170
180
  // appended. Mark that request boundary so pressure resolution skips the
171
181
  // first subsequent assistant representation: its output (including opaque
@@ -183,11 +193,15 @@ export function invalidateProviderContextBaseline(sessionRef) {
183
193
  sessionRef.contextPressureBaselineOutputTokens = null;
184
194
  sessionRef.contextPressureBaselineMessageCount = null;
185
195
  sessionRef.contextPressureBaselineBoundary = null;
196
+ sessionRef.contextPressureBaselinePrefixSignature = null;
197
+ sessionRef.contextPressureBaselineProvider = null;
198
+ sessionRef.contextPressureBaselineModel = null;
199
+ sessionRef.contextPressureBaselineToolSignature = null;
186
200
  sessionRef.contextPressureBaselineUpdatedAt = null;
187
201
  sessionRef.lastContextTokensStaleAfterCompact = true;
188
202
  }
189
203
 
190
- function providerBaselinePressureTokens(messages, sessionRef) {
204
+ function providerBaselinePressureTokens(messages, sessionRef, policy) {
191
205
  if (!Array.isArray(messages) || !sessionRef
192
206
  || sessionRef.lastContextTokensStaleAfterCompact === true) return null;
193
207
  let tokens = positiveTokenInt(sessionRef.contextPressureBaselineTokens);
@@ -196,7 +210,11 @@ function providerBaselinePressureTokens(messages, sessionRef) {
196
210
  const baselineAt = Number(sessionRef.contextPressureBaselineUpdatedAt || 0);
197
211
  const compactAt = Number(sessionRef.compaction?.lastChangedAt || sessionRef.compaction?.lastCompactAt || 0);
198
212
  if (!tokens || !Number.isInteger(count) || count < 0 || count > messages.length
199
- || (compactAt > 0 && baselineAt > 0 && baselineAt < compactAt)) return null;
213
+ || (compactAt > 0 && baselineAt > 0 && baselineAt < compactAt)
214
+ || sessionRef.contextPressureBaselineProvider !== (sessionRef.provider || null)
215
+ || sessionRef.contextPressureBaselineModel !== (sessionRef.model || null)
216
+ || sessionRef.contextPressureBaselineToolSignature !== policy?.toolSchemaSignature
217
+ || sessionRef.contextPressureBaselinePrefixSignature !== contextMessagesSignature(messages, count)) return null;
200
218
  if (sessionRef.contextPressureBaselineBoundary === 'request') {
201
219
  const assistantOffset = messages.slice(count).findIndex(message => message?.role === 'assistant');
202
220
  if (assistantOffset >= 0) {
@@ -213,14 +231,14 @@ function providerBaselinePressureTokens(messages, sessionRef) {
213
231
  const growth = count < messages.length
214
232
  ? estimateMessagesTokens(messages.slice(count))
215
233
  : 0;
216
- return Math.max(0, tokens + growth);
234
+ return Math.max(0, tokens + growth + Math.max(0, Number(policy?.configuredReserveTokens) || 0));
217
235
  } catch {
218
236
  return null;
219
237
  }
220
238
  }
221
239
 
222
240
  export function resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
223
- return providerBaselinePressureTokens(messages, sessionRef)
241
+ return providerBaselinePressureTokens(messages, sessionRef, policy)
224
242
  ?? compactPressureTokens(messageTokensEst, policy);
225
243
  }
226
244
 
@@ -43,6 +43,9 @@ export function classifyTerminationReason(response, {
43
43
  // on its own contract.
44
44
  return 'iteration_cap';
45
45
  }
46
+ if (_finalStopReason === 'refusal') {
47
+ return 'refusal';
48
+ }
46
49
  if (_finalOutputLimitStop || (!_finalHasContent && _finalIncompleteStop)) {
47
50
  // Exhausted token-cap recovery is abnormal even with preserved partial
48
51
  // text. pause_turn/OTHER retain their prior non-empty completion
@@ -223,21 +223,30 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
223
223
  })();
224
224
  if (typeof afterToolHook === 'function') {
225
225
  try {
226
+ // Tool outcome metadata is runtime-internal. Hooks receive the same
227
+ // model-visible result value they received before transient
228
+ // envelopes existed, never the envelope object itself.
229
+ const {
230
+ result: __res,
231
+ newMessages: __nm,
232
+ explicitSuccess: __explicitSuccess,
233
+ } = normalizeToolEnvelope(__result);
226
234
  const hookResult = await afterToolHook({
227
235
  name,
228
236
  args,
229
237
  cwd,
230
238
  sessionId: callerSessionId,
231
239
  toolCallId: executeOpts.toolCallId || null,
232
- result: __result,
240
+ result: __res,
233
241
  });
234
242
  // Envelope-aware hook override: a PostToolUse hook may override the
235
243
  // model-VISIBLE tool output (the envelope's `result` / stub), but it
236
244
  // must NEVER drop the `newMessages` channel. Split first, apply the
237
245
  // override to `result` only, then re-wrap so newMessages survive.
238
- const { result: __res, newMessages: __nm } = normalizeToolEnvelope(__result);
239
246
  const __overridden = resolveToolResultAfterHook(__res, hookResult);
240
- if (__nm.length) return makeToolEnvelope(__overridden, __nm);
247
+ if (__nm.length || __explicitSuccess) {
248
+ return makeToolEnvelope(__overridden, __nm, { explicitSuccess: __explicitSuccess });
249
+ }
241
250
  return __overridden;
242
251
  } catch {
243
252
  // PostToolUse hooks are best-effort; never let one break the tool result.
@@ -341,7 +341,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
341
341
  promptTokens: d.deltaPrompt,
342
342
  cachedTokens: d.deltaCachedRead,
343
343
  cacheWriteTokens: d.deltaCacheWrite,
344
- }, { boundary: 'request' });
344
+ }, { boundary: 'request', sendTools: d.sendTools });
345
345
  }
346
346
  try { askOpts?.onUsageDelta?.(d); } catch {}
347
347
  },
@@ -482,7 +482,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
482
482
  applyAskTerminalUsageTotals(session, result, {
483
483
  skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
484
484
  });
485
- recordProviderContextBaseline(session, session.messages, result.lastTurnUsage || result.usage);
485
+ recordProviderContextBaseline(session, session.messages, result.lastTurnUsage || result.usage, {
486
+ sendTools: result.lastSendTools,
487
+ });
486
488
  // Agent Runtime cache stats — record hit/miss after every successful
487
489
  // ask so the registry reflects all agent traffic, not just
488
490
  // maintenance cycles. Guarded against any agent-runtime error so
@@ -40,6 +40,8 @@
40
40
  * "(no lines in range" — read offset out-of-range (builtin.mjs:739, 3571)
41
41
  *
42
42
  * @param {unknown} result
43
+ * @param {boolean} [explicitSuccess=false] true only when the tool handler
44
+ * explicitly returned `isError: false`
43
45
  * @returns {'normal' | 'error' | 'zero-match'}
44
46
  */
45
47
  const ZERO_MATCH_PREFIXES = [
@@ -56,7 +58,8 @@ const ZERO_MATCH_PREFIXES = [
56
58
  '(no lines in range',
57
59
  ];
58
60
 
59
- export function classifyResultKind(result) {
61
+ export function classifyResultKind(result, explicitSuccess = false) {
62
+ if (explicitSuccess === true) return 'normal';
60
63
  if (typeof result !== 'string') return 'normal';
61
64
  const trimmed = result.trimStart();
62
65
  if (/^error(?:\s+\[code\b|\s*:)/i.test(trimmed) || /^\[error/i.test(trimmed) || /^\[exit code:/i.test(trimmed)) return 'error';
@@ -32,6 +32,11 @@ import { crossTurnSignature, crossTurnDedupStub, isEditProgressTool } from './lo
32
32
  import { getToolKind, isEagerDispatchable, parseNativeToolSearchPayload } from './loop/tool-helpers.mjs';
33
33
  import { restoreToolCallBodyForId, dropCompactedBodyArgsForId } from './loop/stored-tool-args.mjs';
34
34
 
35
+ function classifyToolReturn(value) {
36
+ const normalized = normalizeToolEnvelope(value);
37
+ return classifyResultKind(normalized.result, normalized.explicitSuccess);
38
+ }
39
+
35
40
  export async function processToolBatch(ctx) {
36
41
  const {
37
42
  calls, messages, tools, cwd, sessionId, sessionRef, signal, opts,
@@ -258,7 +263,7 @@ export async function processToolBatch(ctx) {
258
263
  if (!settled.ok) throw settled.error;
259
264
  result = settled.value;
260
265
  toolEndedAt = eager.endedAt ?? Date.now();
261
- const _eagerKind = classifyResultKind(result);
266
+ const _eagerKind = classifyToolReturn(result);
262
267
  if (_eagerKind === 'error') {
263
268
  _resultKind = 'error';
264
269
  _executeOk = false;
@@ -286,7 +291,7 @@ export async function processToolBatch(ctx) {
286
291
  // Boundary: tool-return string convention → structural kind.
287
292
  // The only prefix check in this codebase; downstream layers
288
293
  // operate on _resultKind.
289
- if (classifyResultKind(result) === 'error') {
294
+ if (classifyToolReturn(result) === 'error') {
290
295
  _resultKind = 'error';
291
296
  _executeOk = false;
292
297
  } else {
@@ -5,7 +5,8 @@
5
5
  * - legacy: a string (or existing structured media object) — unchanged, OR
6
6
  * - an envelope object:
7
7
  * { __toolEnvelope: true, result: <string|structured>,
8
- * newMessages: [{ role:'user', content:'...' }, ...] }
8
+ * newMessages: [{ role:'user', content:'...' }, ...],
9
+ * explicitSuccess?: true }
9
10
  *
10
11
  * The `__toolEnvelope` marker is deliberately namespaced so it can NEVER be
11
12
  * confused with the existing structured media content objects that
@@ -37,17 +38,18 @@ function isValidNewMessage(m) {
37
38
  * sees as the tool_result; `newMessages` are appended (as their own
38
39
  * messages, e.g. role:'user') AFTER the batch's tool results.
39
40
  */
40
- export function makeToolEnvelope(result, newMessages = []) {
41
+ export function makeToolEnvelope(result, newMessages = [], options = {}) {
41
42
  return {
42
43
  [TOOL_ENVELOPE_MARKER]: true,
43
44
  result,
44
45
  newMessages: Array.isArray(newMessages) ? newMessages.filter(isValidNewMessage) : [],
46
+ ...(options.explicitSuccess === true ? { explicitSuccess: true } : {}),
45
47
  };
46
48
  }
47
49
 
48
50
  /**
49
- * Split a tool return value into `{ result, newMessages }`.
50
- * - legacy string/object → { result: value, newMessages: [] }
51
+ * Split a tool return value into `{ result, newMessages, explicitSuccess }`.
52
+ * - legacy string/object → { result: value, newMessages: [], explicitSuccess: false }
51
53
  * - envelope → { result, newMessages } (newMessages validated)
52
54
  */
53
55
  export function normalizeToolEnvelope(value) {
@@ -55,7 +57,7 @@ export function normalizeToolEnvelope(value) {
55
57
  const newMessages = Array.isArray(value.newMessages)
56
58
  ? value.newMessages.filter(isValidNewMessage)
57
59
  : [];
58
- return { result: value.result, newMessages };
60
+ return { result: value.result, newMessages, explicitSuccess: value.explicitSuccess === true };
59
61
  }
60
- return { result: value, newMessages: [] };
62
+ return { result: value, newMessages: [], explicitSuccess: false };
61
63
  }
@@ -344,6 +344,15 @@ function stripTrailingPatternArtifacts(v) {
344
344
  return out;
345
345
  }
346
346
 
347
+ function coercePatternStringValues(v) {
348
+ const coerce = (value) => (
349
+ (typeof value === 'number' && Number.isFinite(value)) || typeof value === 'boolean'
350
+ ? String(value)
351
+ : value
352
+ );
353
+ return Array.isArray(v) ? v.map(coerce) : coerce(v);
354
+ }
355
+
347
356
  // ---- per-tool guards ----
348
357
 
349
358
  function guardGrep(a) {
@@ -355,9 +364,10 @@ function guardGrep(a) {
355
364
  // Lossless cleanup of trailing artifacts before validation (item 5b).
356
365
  for (const k of patternKeys) {
357
366
  if (hasOwn(a, k)) {
358
- a[k] = Array.isArray(a[k])
359
- ? a[k].map(stripTrailingPatternArtifacts)
360
- : stripTrailingPatternArtifacts(a[k]);
367
+ const value = coercePatternStringValues(a[k]);
368
+ a[k] = Array.isArray(value)
369
+ ? value.map(stripTrailingPatternArtifacts)
370
+ : stripTrailingPatternArtifacts(value);
361
371
  }
362
372
  }
363
373
 
@@ -376,6 +386,7 @@ function guardGrep(a) {
376
386
  // rg's PCRE2 engine (-P/--pcre2) when the installed rg build supports it,
377
387
  // falling back to this same error text only when PCRE2 is unavailable.
378
388
  for (const k of globKeys) {
389
+ if (hasOwn(a, k)) a[k] = coercePatternStringValues(a[k]);
379
390
  if (hasOwn(a, k) && !isStringOrStringArray(a[k])) {
380
391
  return `Error: grep arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
381
392
  }
@@ -755,6 +766,7 @@ function guardGlob(a) {
755
766
  }
756
767
  }
757
768
  for (const k of globPatternKeys) {
769
+ if (hasOwn(a, k)) a[k] = coercePatternStringValues(a[k]);
758
770
  if (hasOwn(a, k) && !isStringOrStringArray(a[k])) {
759
771
  return `Error: glob arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
760
772
  }
@@ -773,7 +773,7 @@ export function foregroundLongCommandHint(command, timeoutMs, args = {}) {
773
773
  const longSleep = /\bsleep\s+(?:[3-9]\d|\d{3,}|\d+[mh])\b/i.test(cmd) || longPowerShellSleep;
774
774
  if (!watchLike && !longSleep) return '';
775
775
  if (!longTimeout && !watchLike && !longSleep) return '';
776
- return 'Error: long foreground command detected.';
776
+ return 'Error: long foreground command detected. Use mode:"async" (background task) or task wait instead of blocking sleeps/watch loops.';
777
777
  }
778
778
 
779
779
  // Commands that must NOT be promoted to background on a foreground timeout —