mixdog 0.9.18 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +3 -2
  2. package/scripts/output-style-smoke.mjs +2 -2
  3. package/scripts/recall-bench-cases.json +11 -0
  4. package/scripts/recall-bench.mjs +91 -2
  5. package/scripts/tool-efficiency-diag.mjs +4 -1
  6. package/scripts/tool-smoke.mjs +101 -27
  7. package/src/agents/debugger/AGENT.md +3 -3
  8. package/src/agents/heavy-worker/AGENT.md +7 -10
  9. package/src/agents/maintainer/AGENT.md +1 -2
  10. package/src/agents/reviewer/AGENT.md +1 -2
  11. package/src/agents/worker/AGENT.md +5 -8
  12. package/src/defaults/agents.json +3 -0
  13. package/src/mixdog-session-runtime.mjs +23 -6
  14. package/src/rules/agent/00-core.md +4 -3
  15. package/src/rules/agent/30-explorer.md +53 -22
  16. package/src/rules/lead/lead-tool.md +3 -2
  17. package/src/rules/shared/01-tool.md +24 -29
  18. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  21. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  28. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  29. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  30. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  32. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  33. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  34. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  35. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  36. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  37. package/src/runtime/channels/backends/discord.mjs +43 -1
  38. package/src/runtime/channels/index.mjs +145 -58
  39. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  40. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  41. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  42. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  43. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  44. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  45. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  46. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  47. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  48. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  49. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  50. package/src/runtime/shared/atomic-file.mjs +10 -4
  51. package/src/runtime/shared/background-tasks.mjs +4 -2
  52. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  53. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  54. package/src/runtime/shared/tool-surface.mjs +30 -1
  55. package/src/session-runtime/config-lifecycle.mjs +1 -1
  56. package/src/session-runtime/cwd-plugins.mjs +46 -3
  57. package/src/session-runtime/mcp-glue.mjs +24 -3
  58. package/src/session-runtime/output-styles.mjs +44 -10
  59. package/src/session-runtime/workflow.mjs +16 -1
  60. package/src/standalone/channel-worker.mjs +74 -5
  61. package/src/standalone/explore-tool.mjs +1 -1
  62. package/src/tui/App.jsx +57 -77
  63. package/src/tui/app/channel-pickers.mjs +45 -0
  64. package/src/tui/app/slash-commands.mjs +0 -1
  65. package/src/tui/app/slash-dispatch.mjs +0 -16
  66. package/src/tui/app/transcript-window.mjs +44 -1
  67. package/src/tui/app/use-mouse-input.mjs +9 -2
  68. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  69. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  70. package/src/tui/app/use-transcript-window.mjs +65 -5
  71. package/src/tui/components/PromptInput.jsx +33 -64
  72. package/src/tui/components/ToolExecution.jsx +2 -2
  73. package/src/tui/dist/index.mjs +5330 -5443
  74. package/src/tui/engine.mjs +49 -10
  75. package/src/tui/lib/voice-setup.mjs +166 -0
  76. package/src/tui/paste-attachments.mjs +12 -5
  77. package/src/tui/prompt-history-store.mjs +125 -12
  78. package/scripts/bench/cache-probe-tasks.json +0 -8
  79. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  80. package/scripts/bench/lead-review-tasks.json +0 -20
  81. package/scripts/bench/r4-mixed-tasks.json +0 -20
  82. package/scripts/bench/r5-orchestrated-task.json +0 -7
  83. package/scripts/bench/review-tasks.json +0 -20
  84. package/scripts/bench/round-codex.json +0 -114
  85. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  86. package/scripts/bench/round-mixdog-lead.json +0 -269
  87. package/scripts/bench/round-mixdog.json +0 -126
  88. package/scripts/bench/round-r10-bigsample.json +0 -679
  89. package/scripts/bench/round-r11-codexalign.json +0 -257
  90. package/scripts/bench/round-r13-clientmeta.json +0 -464
  91. package/scripts/bench/round-r14-betafeatures.json +0 -466
  92. package/scripts/bench/round-r15-fulldefault.json +0 -462
  93. package/scripts/bench/round-r16-sessionid.json +0 -466
  94. package/scripts/bench/round-r17-wirebytes.json +0 -456
  95. package/scripts/bench/round-r18-prewarm.json +0 -468
  96. package/scripts/bench/round-r19-clean.json +0 -472
  97. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  98. package/scripts/bench/round-r21-delta-retry.json +0 -473
  99. package/scripts/bench/round-r22-full-probe.json +0 -693
  100. package/scripts/bench/round-r23-itemprobe.json +0 -701
  101. package/scripts/bench/round-r24-shapefix.json +0 -677
  102. package/scripts/bench/round-r25-serial.json +0 -464
  103. package/scripts/bench/round-r26-parallel3.json +0 -671
  104. package/scripts/bench/round-r27-parallel10.json +0 -894
  105. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  106. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  107. package/scripts/bench/round-r30-instid.json +0 -253
  108. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  109. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  110. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  111. package/scripts/bench/round-r34-orchestrated.json +0 -120
  112. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  113. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  114. package/scripts/bench/round-r4-codex.json +0 -114
  115. package/scripts/bench/round-r4-mixed.json +0 -225
  116. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  117. package/scripts/bench/round-r6-codex.json +0 -114
  118. package/scripts/bench/round-r6-solo.json +0 -257
  119. package/scripts/bench/round-r7-full.json +0 -254
  120. package/scripts/bench/round-r8-fulldefault.json +0 -255
  121. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  122. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -17,6 +17,27 @@ const MAX_INT = 100000;
17
17
  // defaults to 25 lines; this is only the upper bound for caller-supplied -A/-B/-C.
18
18
  export const GREP_CONTEXT_MAX = 200;
19
19
 
20
+ // Tighter cap for CONTEXT-MODE grep (content_with_context or explicit -A/-B/-C):
21
+ // head_limit counts MATCH BLOCKS in that mode, so ~40 blocks is already a large
22
+ // read. Context width itself (-A/-B/-C) is deliberately NOT special-cased —
23
+ // explicit requests are honored up to the generic GREP_CONTEXT_MAX; total
24
+ // output stays bounded by the block clamp, the stream line cap, and the
25
+ // tool-output byte cap.
26
+ export const GREP_CTX_HEAD_LIMIT_MAX = 40;
27
+
28
+ // Unbounded (no offset/limit) plain full reads default to this window instead of
29
+ // pulling the whole file; the read tool's ranged-read footer then hands the
30
+ // caller the next offset to page with.
31
+ export const READ_GUARD_DEFAULT_LIMIT = 800;
32
+
33
+ // Best-effort clamp notice channel: stash a one-line note on the args so a
34
+ // surfacing consumer can echo it. Underscore-prefixed; ignored by executors.
35
+ function pushClampNotice(a, msg) {
36
+ if (!a || typeof a !== 'object') return;
37
+ if (!Array.isArray(a._clampNotices)) a._clampNotices = [];
38
+ a._clampNotices.push(msg);
39
+ }
40
+
20
41
  // ripgrep-flavored aliases: models trained on `rg` emit short flags (-A/-B/-C),
21
42
  // long flags (--after-context / --before-context / --context), or snake/camel
22
43
  // spellings. All fold onto the canonical -A/-B/-C so a caller can write grep
@@ -154,6 +175,73 @@ function coerceIntegerString(v) {
154
175
  // tells the model exactly what to do instead.
155
176
  const RANGE_SHAPED_INT_STRING = /^\d+\s*[,\-:]\s*\d+$/;
156
177
 
178
+ // Coerce a window arg that may arrive as a real array or a JSON-stringified
179
+ // array ("[0, 0]") into a real array; returns null when it is neither.
180
+ function coerceWindowArray(v) {
181
+ if (Array.isArray(v)) return v;
182
+ if (typeof v === 'string') {
183
+ let c = coerceShapeFlex(v);
184
+ // Double-encoded window strings ('"[0, 0]"') unwrap to the inner
185
+ // string '[0, 0]' on the first flex pass — re-run once to reach the
186
+ // array. Lossless: only applies when the outer layer was a JSON
187
+ // string wrapper.
188
+ if (typeof c === 'string' && c !== v) c = coerceShapeFlex(c);
189
+ if (Array.isArray(c)) return c;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ // Absorb: offset/limit arriving as parallel arrays (or JSON-stringified
195
+ // arrays) alongside a path[] batch — zip each window onto its file as a
196
+ // {path,offset,limit} region object, mirroring the lossless path JSON-string
197
+ // recovery. A length-1 window array applies to every file. Per-entry integer
198
+ // coercion/validation then runs on the resulting region objects. Length
199
+ // mismatches zip positionally (absorb, don't hard-error); a single path with
200
+ // multiple windows expands into regions of that path.
201
+ function maybeZipPathWindowArrays(a) {
202
+ // A single-string path also zips when the windows are ARRAYS (e.g.
203
+ // path:'x.mjs', offset:'[0, 100]') — coerceReadFamilyPathArg unwraps
204
+ // one-element path[] to a string before this runs, so without this the
205
+ // window string would survive to the integer guard and hard-error.
206
+ if (typeof a.path === 'string' && a.path
207
+ && (coerceWindowArray(a.offset) || coerceWindowArray(a.limit))) {
208
+ a.path = [a.path];
209
+ }
210
+ if (!Array.isArray(a.path) || a.path.length < 1) return;
211
+ if (!a.path.every((e) => typeof e === 'string')) return;
212
+ const offs = coerceWindowArray(a.offset);
213
+ const lims = coerceWindowArray(a.limit);
214
+ if (!offs && !lims) return;
215
+ const n = a.path.length;
216
+ const pick = (arr, i) => (arr ? (arr.length === 1 ? arr[0] : arr[i]) : undefined);
217
+ // Single path + multiple windows -> regions of the SAME path (the model
218
+ // asked for several spans of one file).
219
+ const k = Math.max(offs?.length || 0, lims?.length || 0);
220
+ if (n === 1 && k > 1) {
221
+ const p = a.path[0];
222
+ a.path = Array.from({ length: k }, (_, i) => {
223
+ const r = { path: p };
224
+ const o = pick(offs, i); if (o !== undefined && o !== null) r.offset = o;
225
+ const l = pick(lims, i); if (l !== undefined && l !== null) r.limit = l;
226
+ return r;
227
+ });
228
+ delete a.offset;
229
+ delete a.limit;
230
+ return;
231
+ }
232
+ // Positional zip. Length mismatches absorb instead of hard-erroring
233
+ // (seen live: 3 paths + offset "[350, 1]"): missing entries mean
234
+ // full-file read for that path; extra window entries are dropped.
235
+ a.path = a.path.map((p, i) => {
236
+ const r = { path: p };
237
+ const o = pick(offs, i); if (o !== undefined && o !== null) r.offset = o;
238
+ const l = pick(lims, i); if (l !== undefined && l !== null) r.limit = l;
239
+ return r;
240
+ });
241
+ delete a.offset;
242
+ delete a.limit;
243
+ }
244
+
157
245
  // Mutates a[field] in place when it is a lossless integer string, then
158
246
  // validates the (possibly coerced) value against [min, max].
159
247
  function checkIntInRange(a, field, min, max, opts = {}) {
@@ -167,10 +255,16 @@ function checkIntInRange(a, field, min, max, opts = {}) {
167
255
  }
168
256
  }
169
257
  if (typeof value === 'string' && RANGE_SHAPED_INT_STRING.test(value.trim())) {
170
- return `Error: builtin arg "${field}" takes a single integer; for a line range use offset+limit as separate integers or "path#L<start>-L<end>" in the path arg (got "${value}")`;
258
+ return `Error: "${field}" takes one integer, not a range "${value}" — use offset+limit (e.g. offset:0, limit:40)`;
259
+ }
260
+ // Soft-cap fields only: floor a fractional number (e.g. -B: 2.5) instead
261
+ // of erroring — intent is unambiguous for a context-line count.
262
+ if (opts.clamp && typeof value === 'number' && Number.isFinite(value) && !Number.isInteger(value)) {
263
+ value = Math.floor(value);
264
+ a[field] = value;
171
265
  }
172
266
  if (!isFiniteInt(value)) {
173
- return `Error: builtin arg "${field}" must be a finite integer (got ${describeType(value)}); expected a single integer like 40`;
267
+ return `Error: "${field}" must be an integer (got ${describeType(value)}) e.g. ${field}:40`;
174
268
  }
175
269
  // Soft-cap fields (grep -A/-B/-C/context): silently clamp out-of-range
176
270
  // instead of erroring — a model-guessed "300" for a 200-line cap is not
@@ -270,6 +364,18 @@ function guardGrep(a) {
270
364
  }
271
365
  // path/root (optional, string or string[])
272
366
  for (const k of ['path', 'root']) {
367
+ // Absorb JSON-stringified arrays ('["src","docs"]') like the read
368
+ // family does; an empty array ('[]' or []) means "no path filter" —
369
+ // drop the key so the search defaults to cwd instead of handing rg a
370
+ // literal "[]" path (parsed as an unclosed character class).
371
+ if (hasOwn(a, k) && typeof a[k] === 'string') {
372
+ const c = coerceShapeFlex(a[k].trim());
373
+ if (Array.isArray(c)) a[k] = c;
374
+ }
375
+ if (hasOwn(a, k) && Array.isArray(a[k]) && a[k].length === 0) {
376
+ delete a[k];
377
+ continue;
378
+ }
273
379
  if (hasOwn(a, k) && !isStringOrStringArray(a[k])) {
274
380
  return `Error: grep arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
275
381
  }
@@ -309,6 +415,22 @@ function guardGrep(a) {
309
415
  }
310
416
  }
311
417
  }
418
+ // Context-mode tightening: clamp head_limit (which counts MATCH BLOCKS)
419
+ // harder than the generic caps above, and note the clamp for surfacing.
420
+ // Only applies where the executor actually honors context flags — i.e.
421
+ // content_with_context, or content mode with explicit -A/-B/-C. In
422
+ // files_with_matches/count the context flags are ignored, so head_limit
423
+ // there means output lines/paths, not blocks — never clamp it.
424
+ const grepMode = a.output_mode || a.mode;
425
+ const hasExplicitCtx = ['-A', '-B', '-C', 'context'].some((k) => grepContextKeyPresent(a, k));
426
+ const isCountOrFiles = grepMode === 'files_with_matches' || grepMode === 'count';
427
+ if (!isCountOrFiles && (grepMode === 'content_with_context' || hasExplicitCtx)) {
428
+ const hl = Number(a.head_limit);
429
+ if (grepContextKeyPresent(a, 'head_limit') && Number.isFinite(hl) && hl > GREP_CTX_HEAD_LIMIT_MAX) {
430
+ a.head_limit = GREP_CTX_HEAD_LIMIT_MAX;
431
+ pushClampNotice(a, `notice: grep head_limit clamped to ${GREP_CTX_HEAD_LIMIT_MAX} match blocks (context mode)`);
432
+ }
433
+ }
312
434
  return null;
313
435
  }
314
436
 
@@ -339,18 +461,18 @@ function applyLineContextWindow(obj, labelPrefix) {
339
461
  return `Error: read arg "${labelPrefix}context" requires "${labelPrefix}line" to compute a window`;
340
462
  }
341
463
  if (typeof obj.line === 'string' && RANGE_SHAPED_INT_STRING.test(obj.line.trim())) {
342
- return `Error: read arg "${labelPrefix}line" takes a single integer; for a line range use offset+limit as separate integers or "path#L<start>-L<end>" in the path arg (got "${obj.line}")`;
464
+ return `Error: "${labelPrefix}line" takes one integer, not a range "${obj.line}" use offset+limit (e.g. offset:0, limit:40)`;
343
465
  }
344
466
  if (!isFiniteInt(obj.line) || obj.line < 1) {
345
- return `Error: read arg "${labelPrefix}line" must be a finite integer >= 1 (got ${describeType(obj.line)}); expected a single integer like 40`;
467
+ return `Error: "${labelPrefix}line" must be an integer >= 1 (got ${describeType(obj.line)}) e.g. line:40`;
346
468
  }
347
469
  let ctx = 0;
348
470
  if (hasContext) {
349
471
  if (typeof obj.context === 'string' && RANGE_SHAPED_INT_STRING.test(obj.context.trim())) {
350
- return `Error: read arg "${labelPrefix}context" takes a single integer; for a line range use offset+limit as separate integers or "path#L<start>-L<end>" in the path arg (got "${obj.context}")`;
472
+ return `Error: "${labelPrefix}context" takes one integer, not a range "${obj.context}" — e.g. context:5`;
351
473
  }
352
474
  if (!isFiniteInt(obj.context) || obj.context < 0) {
353
- return `Error: read arg "${labelPrefix}context" must be a non-negative finite integer (got ${describeType(obj.context)}); expected a single integer like 40`;
475
+ return `Error: "${labelPrefix}context" must be an integer >= 0 (got ${describeType(obj.context)}) e.g. context:5`;
354
476
  }
355
477
  ctx = obj.context;
356
478
  }
@@ -361,6 +483,43 @@ function applyLineContextWindow(obj, labelPrefix) {
361
483
  return null;
362
484
  }
363
485
 
486
+ // Item 3: an unbounded plain full read (no offset/limit/line/context/mode/n/
487
+ // full, and a bare path with no #Lx / :line coordinate) pulls the whole file.
488
+ // Inject a default window so large files come back capped with a next-offset
489
+ // footer instead. Small files still return in full (limit exceeds line count);
490
+ // explicit windows, modes, and line-spec paths are left untouched.
491
+ function maybeCapUnboundedRead(a) {
492
+ // Effective path may arrive via the file_path alias; cap those the same way.
493
+ let pathStrs = null;
494
+ if (typeof a.path === 'string' && a.path.trim() !== '') {
495
+ pathStrs = [a.path];
496
+ } else if (typeof a.file_path === 'string' && a.file_path.trim() !== '') {
497
+ pathStrs = [a.file_path];
498
+ } else if (Array.isArray(a.path) && a.path.length > 0
499
+ && a.path.every((e) => typeof e === 'string' && e.trim() !== '')) {
500
+ // string[] batch: read-tool expands top-level limit uniformly onto
501
+ // every entry, so bare-path batches take the same default window.
502
+ // Region-object arrays never reach here (they carry their own
503
+ // windows and are rejected above when mixed with top-level limit).
504
+ pathStrs = a.path;
505
+ }
506
+ if (pathStrs === null) return;
507
+ if (a.full === true) return;
508
+ // symbol (and any similar body-selector) composes its own window inside the
509
+ // matched body — a read({path,symbol}) must never be window-truncated.
510
+ for (const k of ['offset', 'limit', 'line', 'context', 'n', 'mode', 'pages', 'symbol']) {
511
+ if (isPresent(a, k)) return;
512
+ }
513
+ // Any #Lx / :line coordinate opts the whole call out: the injected limit
514
+ // would override that entry's own line-spec window during per-entry
515
+ // normalization.
516
+ for (const p of pathStrs) {
517
+ if (p.includes('#')) return;
518
+ if (/:\d+(?:-\d+)?\s*$/.test(p)) return;
519
+ }
520
+ a.limit = READ_GUARD_DEFAULT_LIMIT;
521
+ }
522
+
364
523
  function guardRead(a) {
365
524
  // path / file_path alias OR path may itself be array
366
525
  const hasPath = hasOwn(a, 'path') || hasOwn(a, 'file_path');
@@ -374,6 +533,9 @@ function guardRead(a) {
374
533
  if (hasOwn(a, 'path')) {
375
534
  a.path = coerceReadFamilyPathArg(a.path);
376
535
  }
536
+ // Absorb: parallel/JSON-stringified offset+limit arrays paired with a
537
+ // path[] batch — zip them into per-file region objects before validation.
538
+ maybeZipPathWindowArrays(a);
377
539
  // path can be string | string[] | object[]; file_path is string
378
540
  if (hasOwn(a, 'path')) {
379
541
  const p = a.path;
@@ -382,6 +544,20 @@ function guardRead(a) {
382
544
  return `Error: read arg "path" must be string, string[], or object[] (got ${describeType(p)})`;
383
545
  }
384
546
  if (Array.isArray(p)) {
547
+ // Absorb: a region array ({path,offset,limit}[]) carries its window
548
+ // per-entry; a top-level offset/limit becomes the default for any
549
+ // region missing its own window, then the top-level keys are dropped
550
+ // so they don't double-apply in the plain-read checks below.
551
+ const hasRegionObj = p.some((e) => e && typeof e === 'object' && !Array.isArray(e));
552
+ if (hasRegionObj && (isPresent(a, 'offset') || isPresent(a, 'limit'))) {
553
+ for (const e of p) {
554
+ if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
555
+ if (isPresent(a, 'offset') && !isPresent(e, 'offset')) e.offset = a.offset;
556
+ if (isPresent(a, 'limit') && !isPresent(e, 'limit')) e.limit = a.limit;
557
+ }
558
+ delete a.offset;
559
+ delete a.limit;
560
+ }
385
561
  for (let i = 0; i < p.length; i++) {
386
562
  const entry = p[i];
387
563
  if (typeof entry === 'string') continue;
@@ -400,6 +576,9 @@ function guardRead(a) {
400
576
  if (hasOwn(a, 'file_path') && !isNonEmptyString(a.file_path)) {
401
577
  return `Error: read arg "file_path" must be a non-empty string (got ${describeType(a.file_path)})`;
402
578
  }
579
+ // Item 3: cap unbounded full reads to a paging window (after array/region
580
+ // handling so batched region reads are never touched).
581
+ maybeCapUnboundedRead(a);
403
582
  // Read's public surface is offset/limit, but a top-level line/context pair
404
583
  // is a deterministic, lossless spelling of the same window (matching
405
584
  // read-args.mjs's internal file:line normalizer semantics); convert it
@@ -586,6 +765,19 @@ function guardCodeGraph(a) {
586
765
  if (!CODE_GRAPH_MODES.has(mode)) {
587
766
  return `Error: code_graph arg "mode" must be one of ${[...CODE_GRAPH_MODES].join('|')} (got ${JSON.stringify(a.mode)})`;
588
767
  }
768
+ // Absorb: file/files arriving as a JSON-stringified array
769
+ // (file:"[\"a.mjs\",\"b.mjs\"]") — parse to a real array so the graph
770
+ // lookup batches per file instead of treating the JSON text as one path.
771
+ for (const k of ['file', 'files']) {
772
+ if (hasOwn(a, k) && typeof a[k] === 'string') {
773
+ const c = coerceShapeFlex(a[k]);
774
+ if (Array.isArray(c)) a[k] = c;
775
+ }
776
+ }
777
+ if (Array.isArray(a.file)) {
778
+ a.files = Array.isArray(a.files) ? [...a.file, ...a.files] : a.file;
779
+ delete a.file;
780
+ }
589
781
  return null;
590
782
  }
591
783
 
@@ -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
  }