mixdog 0.9.17 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/package.json +3 -2
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/output-style-smoke.mjs +2 -2
  4. package/scripts/recall-bench-cases.json +11 -0
  5. package/scripts/recall-bench.mjs +91 -2
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/tool-efficiency-diag.mjs +5 -2
  9. package/scripts/tool-smoke.mjs +101 -27
  10. package/src/agents/debugger/AGENT.md +3 -3
  11. package/src/agents/heavy-worker/AGENT.md +7 -10
  12. package/src/agents/maintainer/AGENT.md +1 -2
  13. package/src/agents/reviewer/AGENT.md +1 -2
  14. package/src/agents/worker/AGENT.md +5 -8
  15. package/src/defaults/agents.json +3 -0
  16. package/src/mixdog-session-runtime.mjs +23 -6
  17. package/src/rules/agent/00-core.md +4 -3
  18. package/src/rules/agent/30-explorer.md +53 -22
  19. package/src/rules/lead/02-channels.md +3 -3
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +24 -29
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  25. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  26. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  28. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  32. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  33. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  35. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  36. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  39. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  40. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  41. package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
  42. package/src/runtime/channels/backends/discord.mjs +139 -7
  43. package/src/runtime/channels/index.mjs +290 -76
  44. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  45. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  46. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  47. package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
  48. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  49. package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
  50. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  51. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  52. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  53. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  54. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  55. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  56. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  57. package/src/runtime/shared/atomic-file.mjs +10 -4
  58. package/src/runtime/shared/background-tasks.mjs +4 -2
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  61. package/src/runtime/shared/tool-surface.mjs +30 -1
  62. package/src/session-runtime/config-lifecycle.mjs +1 -1
  63. package/src/session-runtime/cwd-plugins.mjs +46 -3
  64. package/src/session-runtime/mcp-glue.mjs +24 -3
  65. package/src/session-runtime/output-styles.mjs +44 -10
  66. package/src/session-runtime/workflow.mjs +16 -1
  67. package/src/standalone/channel-worker.mjs +88 -7
  68. package/src/standalone/explore-tool.mjs +1 -1
  69. package/src/tui/App.jsx +57 -77
  70. package/src/tui/app/channel-pickers.mjs +45 -0
  71. package/src/tui/app/slash-commands.mjs +0 -1
  72. package/src/tui/app/slash-dispatch.mjs +0 -16
  73. package/src/tui/app/transcript-window.mjs +66 -1
  74. package/src/tui/app/use-mouse-input.mjs +9 -2
  75. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  76. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  77. package/src/tui/app/use-transcript-window.mjs +74 -8
  78. package/src/tui/components/PromptInput.jsx +33 -64
  79. package/src/tui/components/ToolExecution.jsx +2 -2
  80. package/src/tui/dist/index.mjs +4744 -4806
  81. package/src/tui/engine.mjs +109 -20
  82. package/src/tui/lib/voice-setup.mjs +166 -0
  83. package/src/tui/paste-attachments.mjs +12 -5
  84. package/src/tui/prompt-history-store.mjs +125 -12
  85. package/scripts/bench/cache-probe-tasks.json +0 -8
  86. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  87. package/scripts/bench/lead-review-tasks.json +0 -20
  88. package/scripts/bench/r4-mixed-tasks.json +0 -20
  89. package/scripts/bench/r5-orchestrated-task.json +0 -7
  90. package/scripts/bench/review-tasks.json +0 -20
  91. package/scripts/bench/round-codex.json +0 -114
  92. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  93. package/scripts/bench/round-mixdog-lead.json +0 -269
  94. package/scripts/bench/round-mixdog.json +0 -126
  95. package/scripts/bench/round-r10-bigsample.json +0 -679
  96. package/scripts/bench/round-r11-codexalign.json +0 -257
  97. package/scripts/bench/round-r13-clientmeta.json +0 -464
  98. package/scripts/bench/round-r14-betafeatures.json +0 -466
  99. package/scripts/bench/round-r15-fulldefault.json +0 -462
  100. package/scripts/bench/round-r16-sessionid.json +0 -466
  101. package/scripts/bench/round-r17-wirebytes.json +0 -456
  102. package/scripts/bench/round-r18-prewarm.json +0 -468
  103. package/scripts/bench/round-r19-clean.json +0 -472
  104. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  105. package/scripts/bench/round-r21-delta-retry.json +0 -473
  106. package/scripts/bench/round-r22-full-probe.json +0 -693
  107. package/scripts/bench/round-r23-itemprobe.json +0 -701
  108. package/scripts/bench/round-r24-shapefix.json +0 -677
  109. package/scripts/bench/round-r25-serial.json +0 -464
  110. package/scripts/bench/round-r26-parallel3.json +0 -671
  111. package/scripts/bench/round-r27-parallel10.json +0 -894
  112. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  113. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  114. package/scripts/bench/round-r30-instid.json +0 -253
  115. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  116. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  117. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  118. package/scripts/bench/round-r34-orchestrated.json +0 -120
  119. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  120. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  121. package/scripts/bench/round-r4-codex.json +0 -114
  122. package/scripts/bench/round-r4-mixed.json +0 -225
  123. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  124. package/scripts/bench/round-r6-codex.json +0 -114
  125. package/scripts/bench/round-r6-solo.json +0 -257
  126. package/scripts/bench/round-r7-full.json +0 -254
  127. package/scripts/bench/round-r8-fulldefault.json +0 -255
  128. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  129. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -114,7 +114,7 @@ export async function executeBashTool(args, workDir, options = {}) {
114
114
  const requestedCwd = args.cwd ?? args.workdir;
115
115
  const cwdResult = resolveOptionalCwd(requestedCwd, workDir);
116
116
  if (cwdResult.error) return cwdResult.error;
117
- // Session cwd carry-over (Claude Code style, no live shell): when the model
117
+ // Session cwd carry-over (no live shell): when the model
118
118
  // passes an explicit cwd it wins and updates the store on the next probe;
119
119
  // otherwise reuse the last stored cwd for this session if it still exists.
120
120
  const _hasExplicitCwd = typeof requestedCwd === 'string' && requestedCwd.trim() !== '';
@@ -231,7 +231,15 @@ export async function executeBashTool(args, workDir, options = {}) {
231
231
  // run_in_background (already detached) or persistent sessions (handled
232
232
  // far above). Capped below the hard timeout so the 600 s upper bound
233
233
  // stays a separate, later ceiling.
234
- const DEFAULT_AUTO_BACKGROUND_MS = 30_000;
234
+ // Soft config: reuse the sync task-wait budget (30 s, see waitForShellJob
235
+ // default in executeTaskTool) as the default promotion threshold. Override
236
+ // with MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms). This is a soft hint,
237
+ // not a hard cap — the value is clamped below `timeout` so the hard ceiling
238
+ // stays a separate, later bound.
239
+ const _autoBgEnvMs = Number(process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS);
240
+ const DEFAULT_AUTO_BACKGROUND_MS = Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs > 0
241
+ ? Math.floor(_autoBgEnvMs)
242
+ : 30_000;
235
243
  const autoBackgroundMs = runInBackground
236
244
  ? 0
237
245
  : Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
@@ -17,7 +17,7 @@ export const BUILTIN_TOOLS = [
17
17
  name: 'read',
18
18
  title: 'Mixdog Read',
19
19
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
20
- description: 'Read known file path(s). Prefer grep content_with_context or code_graph anchors first. Window with numeric offset+limit. Batch paths/regions as real arrays; adjacent spans in one file = one window, not repeated calls. Dirs use list.',
20
+ description: 'Read known file path(s) with optional numeric offset+limit windows. Batch paths/regions as real arrays in one call. Not for directory listing.',
21
21
  inputSchema: {
22
22
  type: 'object',
23
23
  properties: {
@@ -53,7 +53,7 @@ export const BUILTIN_TOOLS = [
53
53
  name: 'shell',
54
54
  title: 'Mixdog Shell',
55
55
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
56
- description: `Run shell to CHANGE state or RUN programs (git/build/test/run). Never to inspect the filesystem reading, listing, searching, existence checks use the dedicated tools, never shell. Set shell: powershell or bash. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
56
+ description: `Run programs or change system state. Set shell: powershell or bash. Not for reading, listing, or searching files. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
57
57
  inputSchema: {
58
58
  type: 'object',
59
59
  properties: {
@@ -87,7 +87,7 @@ export const BUILTIN_TOOLS = [
87
87
  name: 'grep',
88
88
  title: 'Mixdog Grep',
89
89
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
90
- description: 'Search file contents by text/regex in a known scope. files_with_matches/count for broad anchors, content_with_context for narrow answers. One concept → ONE grep: all variants in pattern[], scopes in path[]; parallel single-pattern greps = packing failure.',
90
+ description: 'Exact text/regex in a KNOWN scope. files_with_matches/count for broad anchors, content_with_context for narrow answers.',
91
91
  inputSchema: {
92
92
  type: 'object',
93
93
  properties: {
@@ -96,30 +96,28 @@ export const BUILTIN_TOOLS = [
96
96
  { type: 'string' },
97
97
  { type: 'array', items: { type: 'string' }, minItems: 1 },
98
98
  ],
99
- description: 'Text/regex. Put synonyms in pattern[] as OR in ONE grep; no serial rewording or equivalent repeats.',
99
+ description: 'Text/regex. Array = variants in one call.',
100
100
  },
101
101
  path: {
102
102
  anyOf: [
103
103
  { type: 'string' },
104
104
  { type: 'array', items: { type: 'string' }, minItems: 1 },
105
105
  ],
106
- description: 'Known narrowest file/dir, or path[] to search several scopes in one call; broad scopes return paths first, then refine from returned paths.',
106
+ description: 'Known file or dir. Array = several scopes.',
107
107
  },
108
108
  glob: {
109
109
  anyOf: [
110
110
  { type: 'string' },
111
111
  { type: 'array', items: { type: 'string' }, minItems: 1 },
112
112
  ],
113
- description: 'Narrow in same grep; no follow-up grep for equivalent scope changes.',
113
+ description: 'Glob filter to narrow scope.',
114
114
  },
115
- output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad scope: files_with_matches/count. Narrow scope: content_with_context; answer from it, skip read unless span is not shown.' },
116
- head_limit: { type: 'number', minimum: 0, description: 'Max output lines; keep small.' },
117
- offset: { type: 'number', minimum: 0, description: 'Skip output lines for paging.' },
115
+ output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad: files_with_matches/count; narrow: content_with_context.' },
116
+ head_limit: { type: 'number', minimum: 0, description: 'Max results.' },
117
+ offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
118
118
  '-A': { type: 'number', minimum: 0, description: 'Lines after each match.' },
119
119
  '-B': { type: 'number', minimum: 0, description: 'Lines before each match.' },
120
- // Description nudges -C 5 on content modes measured fix for
121
- // grep→read same-file re-open round-trips (2026-07 diag).
122
- '-C': { type: 'number', minimum: 0, description: 'Lines before/after each match. Content modes: pass -C 5+ so the hit is edit-ready; skipping it forces a follow-up read.' },
120
+ '-C': { type: 'number', minimum: 0, description: 'Lines before/after each match.' },
123
121
  },
124
122
  required: [],
125
123
  },
@@ -128,7 +126,7 @@ export const BUILTIN_TOOLS = [
128
126
  name: 'glob',
129
127
  title: 'Mixdog Glob',
130
128
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
131
- description: 'Find files by exact glob. Unknown path/name uses find. Multiple patterns/dirs = ONE call with pattern[]/path[], never parallel single-pattern calls.',
129
+ description: 'Find files by exact glob. Batch patterns and roots as arrays in one call.',
132
130
  inputSchema: {
133
131
  type: 'object',
134
132
  properties: {
@@ -156,7 +154,7 @@ export const BUILTIN_TOOLS = [
156
154
  name: 'find',
157
155
  title: 'Mixdog Find Files',
158
156
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
159
- description: 'Find files by partial path/name. Exact structure uses glob. Returns verified paths. Multiple names = ONE call with query[].',
157
+ description: 'Find files by partial path/name. Returns verified paths. Batch names as query[].',
160
158
  inputSchema: {
161
159
  type: 'object',
162
160
  properties: {
@@ -177,7 +175,7 @@ export const BUILTIN_TOOLS = [
177
175
  name: 'list',
178
176
  title: 'Mixdog List Directory',
179
177
  annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
180
- description: 'List known directory entries. Use glob for broad discovery. Multiple dirs = ONE call with path[].',
178
+ description: 'List known directory entries. Batch dirs as path[].',
181
179
  inputSchema: {
182
180
  type: 'object',
183
181
  properties: {
@@ -49,8 +49,13 @@ export function sliceReadBodyByLines(body, origOffset, origLimit) {
49
49
  : firstLine));
50
50
  const totalPart = haveTotal ? ` of ${totalNum}` : '';
51
51
  const moreToRead = haveTotal ? emittedLast < totalNum : finiteLast;
52
+ // Anti-fragmentation: modest remainders get the exact one-window
53
+ // continuation (offset+limit) instead of an open-ended "continue".
54
+ const _remaining = haveTotal ? totalNum - emittedLast : null;
52
55
  const continuationPart = moreToRead && Number.isFinite(emittedLast)
53
- ? `; pass offset:${emittedLast} to continue`
56
+ ? (_remaining !== null && _remaining > 0 && _remaining <= 600
57
+ ? `; ${_remaining} lines left — take the rest in ONE window: offset:${emittedLast}, limit:${_remaining}`
58
+ : `; pass offset:${emittedLast} to continue`)
54
59
  : '';
55
60
  const newFooter = `[lines ${firstLine}-${emittedLast}${totalPart}${continuationPart}]`;
56
61
  return kept.join('\n') + (kept.length ? '\n' : '') + newFooter;
@@ -49,6 +49,20 @@ async function detectReadEncoding(fullPath) {
49
49
  }
50
50
  }
51
51
 
52
+ // Reactive anti-fragmentation merge: per-session (readStateScope) memory of
53
+ // the last requested window per file. When the next windowed read of the
54
+ // same file starts shortly AFTER the previous window (gap <= 200 lines) and
55
+ // the widened result stays modest (<= 400 lines), widen the request to
56
+ // include the gap so the model never needs a third paging call.
57
+ const _readWindowHistoryByScope = new WeakMap();
58
+ function _readWindowHistory(scope) {
59
+ let m = _readWindowHistoryByScope.get(scope);
60
+ if (!m) { m = new Map(); _readWindowHistoryByScope.set(scope, m); }
61
+ return m;
62
+ }
63
+ const _WIDEN_GAP_MAX_LINES = 200;
64
+ const _WIDEN_RESULT_MAX_LINES = 400;
65
+
52
66
  export async function executeSingleReadTool(args, workDir, readStateScope, options = {}, helpers = {}) {
53
67
  const {
54
68
  appendReadContextAdvisory,
@@ -136,12 +150,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
136
150
  const hasLimitArg = args.limit !== undefined && args.limit !== null;
137
151
  const hasRangeArgs = hasOffsetArg || hasLimitArg;
138
152
  const wantFull = args.full === true;
139
- const offset = parseOffsetArg(args.offset);
153
+ let offset = parseOffsetArg(args.offset);
140
154
  // full:true bypasses the default 2000-line cap so the whole file
141
155
  // can be returned in one call; the byte-cap path below still
142
156
  // emits a compact truncation marker when rendered bytes overflow
143
157
  // READ_MAX_OUTPUT_BYTES.
144
- const limit = parseLineLimitArg(args.limit, wantFull ? Infinity : 2000);
158
+ let limit = parseLineLimitArg(args.limit, wantFull ? Infinity : 2000);
159
+ let _widenNote = '';
145
160
  let st;
146
161
  let _statErr;
147
162
  try {
@@ -200,6 +215,23 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
200
215
  }
201
216
  } catch { /* lstat failure is non-fatal; the original `st` is authoritative */ }
202
217
  }
218
+ // Reactive widen + window-history bookkeeping (ranged text reads only).
219
+ if (st && readStateScope && typeof readStateScope === 'object'
220
+ && hasOffsetArg && hasLimitArg && Number.isFinite(limit) && limit > 0) {
221
+ const _wins = _readWindowHistory(readStateScope);
222
+ const prev = _wins.get(fullPath);
223
+ if (prev && offset > prev.end && limit <= _WIDEN_RESULT_MAX_LINES) {
224
+ const gap = offset - prev.end; // unread lines between windows
225
+ const mergedLines = (offset + limit) - prev.end;
226
+ if (gap <= _WIDEN_GAP_MAX_LINES && mergedLines <= _WIDEN_RESULT_MAX_LINES) {
227
+ _widenNote = `[widened: included the ${gap}-line gap after your last window (lines ${prev.start}-${prev.end}) — one span instead of another page]`;
228
+ offset = prev.end;
229
+ limit = mergedLines;
230
+ }
231
+ }
232
+ _wins.set(fullPath, { start: offset + 1, end: offset + limit });
233
+ if (_wins.size > 64) _wins.delete(_wins.keys().next().value);
234
+ }
203
235
  if (!st) {
204
236
  const err = _statErr;
205
237
  const redirected = await tryReadFamilyEnoentRedirect({
@@ -639,8 +671,18 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
639
671
  } else if (Buffer.byteLength(rendered, 'utf8') <= READ_MAX_OUTPUT_BYTES) {
640
672
  const emittedStart = offset + 1;
641
673
  const emittedEnd = offset + sliced.length;
642
- const footer = `[lines ${emittedStart}-${emittedEnd} of ${lineCount}${emittedEnd < lineCount ? `; pass offset:${emittedEnd} to continue` : ''}]`;
674
+ // Anti-fragmentation: when the remainder is modest, hand the
675
+ // model the exact one-window continuation instead of inviting
676
+ // another small page.
677
+ const _remaining = lineCount - emittedEnd;
678
+ const _cont = emittedEnd < lineCount
679
+ ? (_remaining <= 600
680
+ ? `; ${_remaining} lines left — take the rest in ONE window: offset:${emittedEnd}, limit:${_remaining}`
681
+ : `; pass offset:${emittedEnd} to continue`)
682
+ : '';
683
+ const footer = `[lines ${emittedStart}-${emittedEnd} of ${lineCount}${_cont}]`;
643
684
  out += `${out ? '\n' : ''}${footer}`;
685
+ if (_widenNote) out += `\n${_widenNote}`;
644
686
  }
645
687
  }
646
688
  // Smart cap. Only engages when the caller asked for
@@ -163,10 +163,13 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
163
163
  const parentRel = nearestExistingParentRel(workDir, missingPath);
164
164
  if (parentRel) {
165
165
  const resolvedParent = parentRel === '.' ? workDir : resolveAgainstCwd(parentRel, workDir);
166
- const siblings = listSiblings(resolvedParent, 5);
166
+ // Compact hint: drop log/artifact noise, keep at most 3 candidates.
167
+ const siblings = listSiblings(resolvedParent, 12)
168
+ .filter((n) => !/\.(log|log\.\d+|tmp|bak)$/i.test(n))
169
+ .slice(0, 3);
167
170
  if (siblings.length) {
168
171
  const shown = parentRel === '.' ? '.' : normalizeOutputPath(parentRel);
169
- return ` Not found at this path; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}.`;
172
+ return ` Not found; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}.`;
170
173
  }
171
174
  }
172
175
  }
@@ -53,6 +53,7 @@ import {
53
53
  normalizeGrepLine,
54
54
  splitGrepCountPrefix,
55
55
  splitGrepLinePrefix,
56
+ splitGrepLineNumberOnlyPrefix,
56
57
  } from './grep-formatting.mjs';
57
58
  import {
58
59
  cacheGet,
@@ -66,6 +67,8 @@ import { applyGrepContextLeadPolicy, GREP_CONTEXT_MAX, hasUnsupportedRipgrepRege
66
67
  // Default surrounding-lines window applied by output_mode:'content_with_context'
67
68
  // when the caller does not pass an explicit -A/-B/-C/context. Sized to cover a
68
69
  // typical function/block so a match arrives readable without a follow-up read.
70
+ // Explicit -A/-B/-C is honored up to the generic GREP_CONTEXT_MAX (no tighter
71
+ // context-mode clamp by policy); only head_limit blocks are context-clamped.
69
72
  const GREP_AUTO_CONTEXT_LINES = 25;
70
73
 
71
74
  const MIXDOG_GREP_CASE_HINT_PROBE = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_GREP_CASE_HINT_PROBE || ''));
@@ -361,12 +364,116 @@ function buildGrepChunkMergePrefix(patternChunkCount, truncated, aggregateBudget
361
364
  return `[${parts.join('; ')}]\n`;
362
365
  }
363
366
 
367
+ // --- context-mode match-block windowing (Parts 2 & 3) ---------------------
368
+ // In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
369
+ // and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
370
+ // head+tail slice with a middle marker instead of dropping the tail.
371
+ function grepBlockMatchAnchor(line, filenameOmitted) {
372
+ if (filenameOmitted) {
373
+ const p = splitGrepLineNumberOnlyPrefix(line);
374
+ return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
375
+ }
376
+ const s = splitGrepLinePrefix(line);
377
+ return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
378
+ }
379
+
380
+ function parseGrepContextBlocks(lines, filenameOmitted) {
381
+ const blocks = [];
382
+ let pending = [];
383
+ let i = 0;
384
+ while (i < lines.length) {
385
+ const line = lines[i];
386
+ if (line === '--') { pending = []; i++; continue; }
387
+ const anchor = grepBlockMatchAnchor(line, filenameOmitted);
388
+ if (anchor) {
389
+ const blockLines = pending.concat([line]);
390
+ pending = [];
391
+ i++;
392
+ while (i < lines.length) {
393
+ const next = lines[i];
394
+ if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
395
+ blockLines.push(next);
396
+ i++;
397
+ }
398
+ blocks.push({ anchor, lines: blockLines });
399
+ continue;
400
+ }
401
+ pending.push(line);
402
+ i++;
403
+ }
404
+ return blocks;
405
+ }
406
+
407
+ function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
408
+ const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
409
+ const blocks = parseGrepContextBlocks(norm, filenameOmitted);
410
+ const total = blocks.length;
411
+ if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
412
+ // Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
413
+ // read (stdout cap / stream cap) it is a lower bound, so print ">=T".
414
+ const totalStr = totalKnown ? `${total}` : `>=${total}`;
415
+ const afterOffset = offset > 0 ? blocks.slice(offset) : blocks;
416
+ if (afterOffset.length === 0) {
417
+ // On a partial stream (line cap / timeout) the parsed blocks are a
418
+ // lower bound — an offset beyond them is NOT proven past the last
419
+ // match, so steer toward narrowing instead of claiming "past end".
420
+ const text = totalKnown
421
+ ? `[Showing 0 of ${totalStr} matches; offset ${offset} past end]`
422
+ : `[Showing 0 of ${totalStr} matches (results partial); offset ${offset} is beyond the streamed window — matches past it may exist. Narrow path/glob/pattern instead of paging deeper.]`;
423
+ return { text, total, shown: 0, omitted: 0 };
424
+ }
425
+ const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
426
+ const omitted = afterOffset.length - shown;
427
+ const render = (arr) => arr.map((b) => b.lines.join('\n'));
428
+ let segments;
429
+ let nextOffset = offset + shown;
430
+ if (omitted > 0 && shown > 0) {
431
+ // Keep head + tail so both ends of the match range stay visible.
432
+ const headCount = Math.max(1, Math.ceil(shown / 2));
433
+ const tailCount = shown - headCount;
434
+ const head = render(afterOffset.slice(0, headCount));
435
+ const tail = tailCount > 0 ? render(afterOffset.slice(afterOffset.length - tailCount)) : [];
436
+ segments = [...head, `…${omitted} matches omitted…`, ...tail];
437
+ // Paging must resume at the first OMITTED block (right after the head
438
+ // slice): offset+shown would permanently skip the middle blocks that
439
+ // the tail slice displaced. Tail blocks re-appear on later pages —
440
+ // duplication is acceptable, silent loss is not.
441
+ nextOffset = offset + headCount;
442
+ } else {
443
+ segments = render(afterOffset.slice(0, shown));
444
+ }
445
+ const notice = (omitted > 0 || !totalKnown)
446
+ ? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
447
+ : '';
448
+ return { text: segments.join('\n--\n') + notice, total, shown, omitted };
449
+ }
450
+
451
+ // Part 1: drop path:line match lines already emitted by an earlier pattern in
452
+ // a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
453
+ function dedupeFanoutMatchLines(body, seen) {
454
+ const text = String(body);
455
+ if (/^Error:/.test(text)) return text;
456
+ const out = [];
457
+ for (const line of text.split('\n')) {
458
+ const s = splitGrepLinePrefix(line);
459
+ if (s && s.delimiter === ':') {
460
+ const key = `${s.path}\0${s.lineNo}`;
461
+ if (seen.has(key)) continue;
462
+ seen.add(key);
463
+ }
464
+ out.push(line);
465
+ }
466
+ return out.join('\n');
467
+ }
468
+
364
469
  function formatGrepOutput({ windowed, totalWindowed, totalKnown, headLimit, offset, outputMode, patterns: _patterns, beforeN, afterN, contextN, searchPath, grepResolvedPath: _grepResolvedPath, workDir, globPatterns: _globPatterns, fileType: _fileType, filenameOmitted = false, prefix = '', broadAdvisory: _broadAdvisory = true, disableContentGrouping = false }) {
365
470
  const lines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
366
471
  const normalized = lines.map((line) => relativeGrepLine(line, workDir, outputMode === 'files_with_matches', outputMode, filenameOmitted));
367
472
  const remaining = Math.max(0, totalWindowed - lines.length);
368
473
  const shown = lines.length;
369
- const total = totalWindowed;
474
+ // Finding 3: PRE-offset grand total so the denominator matches the
475
+ // context-mode notice (offset==0 leaves this unchanged).
476
+ const total = offset + totalWindowed;
370
477
  const scopePath = JSON.stringify(normalizeOutputPath(searchPath));
371
478
  const truncated = (remaining > 0 || !totalKnown)
372
479
  ? (totalKnown
@@ -604,6 +711,33 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
604
711
  if (fileTypes.length > 1) fileType = fileTypes;
605
712
  else if (fileTypes.length === 1) fileType = fileTypes[0];
606
713
 
714
+ // Part 1: pattern[] fan-out. Two or more patterns in a content search run
715
+ // as INDEPENDENT greps (mirroring the path[] batching above): each pattern
716
+ // keeps its own full head_limit budget and its own truncation notice, and
717
+ // identical path:line match lines are de-duplicated across patterns. The
718
+ // single-pattern path and the non-content modes (files_with_matches/count)
719
+ // keep the combined single-rg behavior. `_grepPatternFanout` guards the
720
+ // recursive single-pattern calls from re-entering the fan-out; the internal
721
+ // chunk-merge recursion is likewise skipped.
722
+ if (patterns.length > 1
723
+ && outputMode === 'content'
724
+ && !options._grepChunkMerge
725
+ && !options._grepPatternFanout) {
726
+ const seen = new Set();
727
+ const subOptions = { ...options, _grepPatternFanout: true };
728
+ const parts = [];
729
+ for (const p of patterns) {
730
+ let sub;
731
+ try {
732
+ sub = await executeGrepTool({ ...args, pattern: p }, workDir, executeChildBuiltinTool, readStateScope, subOptions);
733
+ } catch (err) {
734
+ sub = `Error: ${err && err.message ? err.message : err}`;
735
+ }
736
+ parts.push(`# grep pattern:${JSON.stringify(p)}\n${dedupeFanoutMatchLines(sub, seen)}`);
737
+ }
738
+ return patternCapNote + parts.join('\n\n');
739
+ }
740
+
607
741
  const patternChunkCap = multilineMode ? GREP_MULTILINE_PATTERN_CAP : GREP_ARRAY_PATTERN_CAP;
608
742
  if (patterns.length > patternChunkCap) {
609
743
  const patternChunks = chunkPatternList(patterns, patternChunkCap);
@@ -676,7 +810,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
676
810
  });
677
811
  }
678
812
 
679
- const forceGrepFilename = !!options._grepChunkMerge;
813
+ const forceGrepFilename = !!options._grepChunkMerge || !!options._grepPatternFanout;
680
814
  const cacheKey = buildGrepCacheKey({
681
815
  patterns,
682
816
  searchPath: normalizeOutputPath(grepResolvedPath),
@@ -775,6 +909,64 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
775
909
  pcre2: pcre2Mode,
776
910
  withFilename: forceGrepFilename,
777
911
  });
912
+ // Parts 2 & 3: context mode windows MATCH BLOCKS (not raw lines); the
913
+ // total match count and tail blocks come from the streamed (memory-
914
+ // bounded) collection below.
915
+ const contextMode = outputMode === 'content' && (beforeN > 0 || afterN > 0 || contextN > 0);
916
+ if (contextMode) {
917
+ // Finding 1: stream only enough lines to satisfy the block window
918
+ // (offset + head_limit + tail reserve), so rg is stopped early and a
919
+ // broad content_with_context never retains a full 20MB stdout copy.
920
+ // A cap hit → complete:false → partial (lower-bound) phrasing below.
921
+ const GREP_CONTEXT_LINE_HARD_CAP = 4000;
922
+ const perBlock = 2 + (beforeN || 0) + (afterN || 0) + 2 * (contextN || 0);
923
+ const blockBudget = headLimit === Infinity ? Infinity : offset + headLimit + 4;
924
+ const lineCap = blockBudget === Infinity
925
+ ? GREP_CONTEXT_LINE_HARD_CAP
926
+ : Math.min(GREP_CONTEXT_LINE_HARD_CAP, Math.max(200, blockBudget * Math.max(1, perBlock) + 8));
927
+ let ctxPartialSuffix = '';
928
+ const streamed = await runRgWindowedLines(rgArgs, { cwd: rgSpawnCwd }, { offset: 0, limit: lineCap, summaryLimit: 0 });
929
+ const allLines = streamed.lines;
930
+ let ctxTotalKnown = streamed.complete;
931
+ if (streamed.partial) {
932
+ ctxTotalKnown = false;
933
+ ctxPartialSuffix = streamed.timeout
934
+ ? '\n[warning] rg timed out; partial results shown. Narrow path/glob/pattern for a complete result.'
935
+ : streamed.rgStderr
936
+ ? `\n[warning] rg exit 2 (partial results): ${String(streamed.rgStderr).trim().slice(0, 300)}`
937
+ : '\n[warning] rg exit 2 (partial results)';
938
+ } else if (!streamed.complete) {
939
+ ctxPartialSuffix = `\n[warning] context output capped at ${lineCap} lines to bound memory; results partial — narrow path/glob/pattern for the full match set.`;
940
+ }
941
+ const ctx = formatGrepContextOutput({
942
+ allLines,
943
+ workDir,
944
+ outputMode,
945
+ filenameOmitted,
946
+ headLimit,
947
+ offset,
948
+ totalKnown: ctxTotalKnown,
949
+ });
950
+ let ctxBody = ctx.text;
951
+ if (!ctxBody) {
952
+ const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
953
+ const globStr = normalizedGlobPatterns.length > 0 ? ` glob=${JSON.stringify(normalizedGlobPatterns)}` : '';
954
+ const pathInfo = grepStat.isDirectory() ? 'path exists (dir)' : 'path exists (file)';
955
+ ctxBody = `(no matches) pattern=${patternStr} path=${searchPath}${globStr}; ${pathInfo}`;
956
+ }
957
+ const ctxOut = patternCapNote + ctxBody + ctxPartialSuffix;
958
+ if (options?.scopedCacheOutcome && (!ctxTotalKnown || ctx.omitted > 0)) {
959
+ markScopedCacheIncomplete(options.scopedCacheOutcome);
960
+ }
961
+ recordGrepReadSnapshot(grepStat);
962
+ if (ctxTotalKnown && ctx.omitted === 0) {
963
+ cacheSet(cacheKey, ctxOut, { scopes: [grepResolvedPath] });
964
+ }
965
+ if (typeof options?.onProgress === 'function') {
966
+ try { options.onProgress(`found ${ctx.total} matches`); } catch { /* best-effort */ }
967
+ }
968
+ return ctxOut;
969
+ }
778
970
  let windowed;
779
971
  let totalWindowed = 0;
780
972
  let totalKnown = true;
@@ -832,7 +1024,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
832
1024
  globPatterns: normalizedGlobPatterns,
833
1025
  fileType,
834
1026
  filenameOmitted,
835
- disableContentGrouping: !!options._grepChunkMerge,
1027
+ disableContentGrouping: !!options._grepChunkMerge || !!options._grepPatternFanout,
836
1028
  });
837
1029
  if (!body) {
838
1030
  const pathInfo = grepStat.isDirectory() ? 'path exists (dir)' : 'path exists (file)';
@@ -297,6 +297,7 @@ const EXTERNAL_TOOL_REDIRECTS = new Map([
297
297
  ['codebase_search', 'use the `grep` or `code_graph` tool'],
298
298
  ['semanticsearch', 'use the `grep` or `code_graph` tool'],
299
299
  ['semantic_search', 'use the `grep` or `code_graph` tool'],
300
+ ['explore', 'not available in this role — use `grep` or `code_graph` for repo search'],
300
301
  ]);
301
302
 
302
303
  export function canonicalizeBuiltinToolName(name) {
@@ -520,7 +521,22 @@ export async function executeBuiltinTool(name, args, cwd, options = {}) {
520
521
  }
521
522
  })();
522
523
  const _withNudge = _maybeAppendGrepSweepNudge(toolName, args, _toolResult, readStateScope);
523
- return capToolOutput(_withNudge, options);
524
+ return capToolOutput(_appendClampNotices(args, _withNudge), options);
525
+ }
526
+
527
+ // Surface arg-guard clamp notices (args._clampNotices, see pushClampNotice in
528
+ // arg-guard.mjs) on the result text so the caller learns its request was
529
+ // capped. Delete to consume: prevents double-append when a child-builtin
530
+ // result is embedded in the parent's, and leaves no `_clampNotices` residue
531
+ // on the recorded tool-call args in the transcript.
532
+ function _appendClampNotices(args, result) {
533
+ let notices = null;
534
+ if (args && Array.isArray(args._clampNotices)) {
535
+ notices = args._clampNotices;
536
+ delete args._clampNotices;
537
+ }
538
+ if (!notices || notices.length === 0 || typeof result !== 'string') return result;
539
+ return `${result}\n\n${notices.map((n) => `[arg-guard] ${n}`).join('\n')}`;
524
540
  }
525
541
  /**
526
542
  * Check if a tool name is a builtin tool.
@@ -51,6 +51,36 @@ function _collectGraphSymbolList(args) {
51
51
 
52
52
  const CODE_GRAPH_FILE_BATCH_CAP = 20;
53
53
 
54
+ // Absorb: file/files arriving as a JSON-stringified array
55
+ // (file:"[\"a.mjs\",\"b.mjs\"]") — parse to a real array so the graph lookup
56
+ // batches per file instead of treating the JSON text as one (missing) path.
57
+ function _parseJsonArrayString(v) {
58
+ if (typeof v !== 'string') return null;
59
+ const t = v.trim();
60
+ if (!t.startsWith('[') || !t.endsWith(']')) return null;
61
+ try {
62
+ const parsed = JSON.parse(t);
63
+ if (Array.isArray(parsed)) return parsed.map((x) => String(x || '').trim()).filter(Boolean);
64
+ } catch { /* not JSON — leave untouched */ }
65
+ return null;
66
+ }
67
+
68
+ function _normalizeGraphFileArgs(args) {
69
+ if (!args || typeof args !== 'object') return args;
70
+ const fileArr = _parseJsonArrayString(args.file);
71
+ const filesArr = _parseJsonArrayString(args.files);
72
+ if (!fileArr && !filesArr) return args;
73
+ const out = { ...args };
74
+ if (fileArr) { out.files = Array.isArray(out.files) ? [...fileArr, ...out.files] : fileArr; delete out.file; }
75
+ if (filesArr) out.files = filesArr;
76
+ // Collapse a lone entry back to the single-file field for the fast path.
77
+ if (Array.isArray(out.files) && out.files.length === 1 && !out.file) {
78
+ out.file = out.files[0];
79
+ delete out.files;
80
+ }
81
+ return out;
82
+ }
83
+
54
84
  function _collectGraphFileList(args) {
55
85
  const split = (s) => String(s || '').split(/,+/).map((t) => t.trim()).filter(Boolean);
56
86
  const list = [...new Set([
@@ -70,6 +100,13 @@ async function codeGraph(args, cwd, signal = null, options = {}) {
70
100
  let mode = String(args?.mode || '').trim();
71
101
  if (!mode) throw new Error('code_graph: "mode" is required');
72
102
  if (mode === 'search') mode = 'symbol_search';
103
+ // Name-only "symbols" calls (symbols[]/symbol without a file) are symbol
104
+ // lookups, not a file outline — absorb into symbol_search instead of
105
+ // erroring "file not found in graph: (missing file)".
106
+ if (mode === 'symbols' && !String(args?.file || '').trim()
107
+ && ((Array.isArray(args?.symbols) && args.symbols.length) || String(args?.symbol || '').trim())) {
108
+ mode = 'symbol_search';
109
+ }
73
110
 
74
111
  if (mode === 'prewarm') {
75
112
  const _splitMulti = (s) => String(s || '').split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
@@ -436,6 +473,7 @@ export async function resolveSymbolReadSpan(cwd, { symbol, path = null, language
436
473
 
437
474
  export async function executeCodeGraphTool(name, args, cwd, signal = null, options = {}) {
438
475
  if (!cwd) throw new Error('find_symbol/code_graph requires cwd — caller did not provide a working directory');
476
+ args = _normalizeGraphFileArgs(args);
439
477
  const fileArg = (args && typeof args.file === 'string' && args.file.trim()) ? args.file.trim() : '';
440
478
  const baseCwd = (args && typeof args.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
441
479
  let effectiveCwd = baseCwd;
@@ -3,13 +3,13 @@ export const CODE_GRAPH_TOOL_DEFS = [
3
3
  name: 'code_graph',
4
4
  title: 'Code Graph',
5
5
  annotations: { title: 'Code Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false, compressibleLossless: true },
6
- description: 'Repo-local code structure/flow lookup, not web: symbols/references/calls/deps before text grep. Multiple targets = ONE call with symbols[]/files[], never parallel single-target calls.',
6
+ description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps. Batch targets as symbols[]/files[] in one call.',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {
10
- mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers'], description: 'Repo-local operation; code flow before text search.' },
11
- files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file(s): string or array; one call fans out per file (imports/dependents/related/impact/symbols/overview). `file` singular alias also accepted.' },
12
- symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s): string or array; batch in one call. One anchor is enough. `symbol` singular alias also accepted.' },
10
+ mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers'], description: 'Repo-local graph operation.' },
11
+ files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file(s); array fans out per file. `file` alias too.' },
12
+ symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s); array batches in one call. `symbol` alias too.' },
13
13
  symbol: { type: 'string', description: 'Singular alias for symbols.' },
14
14
  file: { type: 'string', description: 'Singular alias for files.' },
15
15
  body: { type: 'boolean', description: 'Include body.' },
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // Claude Code-style shell state carry-over for the one-shot shell tool.
2
+ // Shell state carry-over for the one-shot shell tool.
3
3
  //
4
4
  // No live shell process is kept. Instead, after each sync command we chain a
5
5
  // trailing cwd probe that writes the final working directory to a per-session
@@ -5,13 +5,13 @@ import { join, sep } from "path";
5
5
 
6
6
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
7
7
 
8
- async function downloadSingleAttachment(att, inboxDir) {
8
+ async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } = {}) {
9
9
  if (att.size > MAX_ATTACHMENT_BYTES) {
10
10
  throw new Error(
11
11
  `attachment too large: ${(att.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
12
12
  );
13
13
  }
14
- const res = await fetch(att.url, { signal: AbortSignal.timeout(180_000) });
14
+ const res = await fetch(att.url, { signal: AbortSignal.timeout(timeoutMs) });
15
15
  if (!res.ok) {
16
16
  throw new Error(`attachment download failed: HTTP ${res.status}`);
17
17
  }