mixdog 0.9.68 → 0.9.70

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 (64) hide show
  1. package/package.json +8 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  11. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  12. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  13. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  14. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  16. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  18. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  19. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  20. package/src/runtime/channels/lib/owned-runtime.mjs +8 -1
  21. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  22. package/src/runtime/channels/lib/webhook.mjs +13 -1
  23. package/src/runtime/media/adapters/codex-image.mjs +109 -0
  24. package/src/runtime/media/adapters/gemini-image.mjs +68 -0
  25. package/src/runtime/media/adapters/gemini-video.mjs +119 -0
  26. package/src/runtime/media/adapters/xai-media.mjs +116 -0
  27. package/src/runtime/media/auth.mjs +51 -0
  28. package/src/runtime/media/index.mjs +17 -0
  29. package/src/runtime/media/jobs.mjs +174 -0
  30. package/src/runtime/media/lanes.mjs +230 -0
  31. package/src/runtime/media/store.mjs +164 -0
  32. package/src/runtime/media/upstream-error.mjs +39 -0
  33. package/src/session-runtime/channel-config-api.mjs +12 -1
  34. package/src/session-runtime/media-api.mjs +47 -0
  35. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  36. package/src/session-runtime/model-capabilities.mjs +6 -4
  37. package/src/session-runtime/model-route-api.mjs +4 -1
  38. package/src/session-runtime/notification-bus.mjs +82 -0
  39. package/src/session-runtime/provider-auth-api.mjs +16 -1
  40. package/src/session-runtime/provider-usage.mjs +3 -0
  41. package/src/session-runtime/remote-control.mjs +166 -0
  42. package/src/session-runtime/remote-transcript.mjs +126 -0
  43. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  44. package/src/session-runtime/runtime-core.mjs +160 -706
  45. package/src/session-runtime/runtime-tunables.mjs +57 -0
  46. package/src/session-runtime/self-update.mjs +129 -0
  47. package/src/session-runtime/skills-api.mjs +77 -0
  48. package/src/session-runtime/tool-surface.mjs +83 -0
  49. package/src/session-runtime/warmup-schedulers.mjs +14 -1
  50. package/src/session-runtime/workflow-agents-api.mjs +235 -7
  51. package/src/session-runtime/workflow.mjs +84 -17
  52. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  53. package/src/standalone/agent-tool.mjs +22 -346
  54. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  55. package/src/standalone/channel-worker.mjs +7 -13
  56. package/src/tui/components/StatusLine.jsx +6 -5
  57. package/src/tui/dist/index.mjs +144 -95
  58. package/src/tui/engine/labels.mjs +0 -8
  59. package/src/tui/engine/session-api-ext.mjs +31 -8
  60. package/src/tui/engine/turn-watchdog.mjs +92 -0
  61. package/src/tui/engine/turn.mjs +18 -70
  62. package/src/tui/engine.mjs +39 -1
  63. package/src/workflows/default/WORKFLOW.md +1 -1
  64. package/src/workflows/solo/WORKFLOW.md +1 -1
@@ -0,0 +1,154 @@
1
+ // Grep output formatting: line/block normalization, context-block windowing
2
+ // (head_limit + offset count MATCH BLOCKS, truncation keeps head+tail), the
3
+ // pattern[] fan-out dedupe and the plain windowed renderer. Extracted from
4
+ // search-tool.mjs, which keeps argument handling and rg execution.
5
+ import { normalizeOutputPath } from '../path-utils.mjs';
6
+ import {
7
+ groupGrepContentByFile,
8
+ splitGrepLineNumberOnlyPrefix,
9
+ splitGrepLinePrefix,
10
+ } from '../grep-formatting.mjs';
11
+ import { relativeGrepLine } from './search-input-helpers.mjs';
12
+
13
+ export function grepMissingPatternMessage() {
14
+ return 'Error: grep requires pattern.';
15
+ }
16
+
17
+ export function globMissingPatternMessage() {
18
+ return 'Error: glob requires pattern.';
19
+ }
20
+
21
+ // --- context-mode match-block windowing (Parts 2 & 3) ---------------------
22
+ // In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
23
+ // and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
24
+ // head+tail slice with a middle marker instead of dropping the tail.
25
+ function grepBlockMatchAnchor(line, filenameOmitted) {
26
+ if (filenameOmitted) {
27
+ const p = splitGrepLineNumberOnlyPrefix(line);
28
+ return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
29
+ }
30
+ const s = splitGrepLinePrefix(line);
31
+ return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
32
+ }
33
+
34
+ function parseGrepContextBlocks(lines, filenameOmitted) {
35
+ const blocks = [];
36
+ let pending = [];
37
+ let i = 0;
38
+ while (i < lines.length) {
39
+ const line = lines[i];
40
+ if (line === '--') { pending = []; i++; continue; }
41
+ const anchor = grepBlockMatchAnchor(line, filenameOmitted);
42
+ if (anchor) {
43
+ const blockLines = pending.concat([line]);
44
+ pending = [];
45
+ i++;
46
+ while (i < lines.length) {
47
+ const next = lines[i];
48
+ if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
49
+ blockLines.push(next);
50
+ i++;
51
+ }
52
+ blocks.push({ anchor, lines: blockLines });
53
+ continue;
54
+ }
55
+ pending.push(line);
56
+ i++;
57
+ }
58
+ return blocks;
59
+ }
60
+
61
+ export function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
62
+ const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
63
+ const blocks = parseGrepContextBlocks(norm, filenameOmitted);
64
+ const total = blocks.length;
65
+ if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
66
+ // Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
67
+ // read (stdout cap / stream cap) it is a lower bound, so print ">=T".
68
+ const totalStr = totalKnown ? `${total}` : `>=${total}`;
69
+ const afterOffset = offset > 0 ? blocks.slice(offset) : blocks;
70
+ if (afterOffset.length === 0) {
71
+ // On a partial stream (line cap / timeout) the parsed blocks are a
72
+ // lower bound — an offset beyond them is NOT proven past the last
73
+ // match, so steer toward narrowing instead of claiming "past end".
74
+ const text = totalKnown
75
+ ? `[Showing 0 of ${totalStr} matches; offset ${offset} past end]`
76
+ : `[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.]`;
77
+ return { text, total, shown: 0, omitted: 0 };
78
+ }
79
+ const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
80
+ const omitted = afterOffset.length - shown;
81
+ const render = (arr) => arr.map((b) => b.lines.join('\n'));
82
+ let segments;
83
+ let nextOffset = offset + shown;
84
+ if (omitted > 0 && shown > 0) {
85
+ // Keep head + tail so both ends of the match range stay visible.
86
+ const headCount = Math.max(1, Math.ceil(shown / 2));
87
+ const tailCount = shown - headCount;
88
+ const head = render(afterOffset.slice(0, headCount));
89
+ const tail = tailCount > 0 ? render(afterOffset.slice(afterOffset.length - tailCount)) : [];
90
+ segments = [...head, `…${omitted} matches omitted…`, ...tail];
91
+ // Paging must resume at the first OMITTED block (right after the head
92
+ // slice): offset+shown would permanently skip the middle blocks that
93
+ // the tail slice displaced. Tail blocks re-appear on later pages —
94
+ // duplication is acceptable, silent loss is not.
95
+ nextOffset = offset + headCount;
96
+ } else {
97
+ segments = render(afterOffset.slice(0, shown));
98
+ }
99
+ const notice = (omitted > 0 || !totalKnown)
100
+ ? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
101
+ : '';
102
+ return { text: segments.join('\n--\n') + notice, total, shown, omitted };
103
+ }
104
+
105
+ // Part 1: drop path:line match lines already emitted by an earlier pattern in
106
+ // a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
107
+ export function dedupeFanoutMatchLines(body, seen) {
108
+ const text = String(body);
109
+ if (/^Error:/.test(text)) return text;
110
+ const out = [];
111
+ for (const line of text.split('\n')) {
112
+ const s = splitGrepLinePrefix(line);
113
+ if (s && s.delimiter === ':') {
114
+ const key = `${s.path}\0${s.lineNo}`;
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ }
118
+ out.push(line);
119
+ }
120
+ return out.join('\n');
121
+ }
122
+
123
+ export 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 }) {
124
+ const lines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
125
+ const normalized = lines.map((line) => relativeGrepLine(line, workDir, outputMode === 'files_with_matches', outputMode, filenameOmitted));
126
+ const remaining = Math.max(0, totalWindowed - lines.length);
127
+ const shown = lines.length;
128
+ // Finding 3: PRE-offset grand total so the denominator matches the
129
+ // context-mode notice (offset==0 leaves this unchanged).
130
+ const total = offset + totalWindowed;
131
+ const scopePath = JSON.stringify(normalizeOutputPath(searchPath));
132
+ const truncated = (remaining > 0 || !totalKnown)
133
+ ? (totalKnown
134
+ ? `\n[Showing ${shown} of ${total} results; pass offset:${offset + shown} for more]`
135
+ : `\n[Showing ${shown} (more matches exist — use output_mode:'count' for the exact total on ${scopePath}); pass offset:${offset + shown} for more]`)
136
+ : '';
137
+
138
+ let countSummary = '';
139
+ if (outputMode === 'count') {
140
+ let totalMatches = 0;
141
+ let fileCount = 0;
142
+ for (const line of normalized) {
143
+ const m = line.match(/(?:^|:)(\d+)$/);
144
+ if (m) { totalMatches += Number(m[1]); fileCount++; }
145
+ }
146
+ countSummary = `\n[total ${totalMatches} match${totalMatches === 1 ? '' : 'es'} across ${fileCount} file${fileCount === 1 ? '' : 's'}]`;
147
+ }
148
+ const hasContext = (beforeN > 0 || afterN > 0 || contextN > 0);
149
+ const groupedBody = (outputMode === 'content' && !hasContext && !filenameOmitted && !disableContentGrouping)
150
+ ? groupGrepContentByFile(normalized)
151
+ : normalized.join('\n');
152
+ const body = groupedBody + truncated + countSummary;
153
+ return `${prefix}${body}`;
154
+ }
@@ -141,13 +141,10 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
141
141
  }
142
142
  // Image files (png/jpg/jpeg/gif/webp): return an MCP image block so the
143
143
  // model can actually SEE the image. native Read does this, but mixdog's
144
- // Read is shim-blocked, so this is the only image-view path. Single string
145
- // path only batch and head/tail/count modes stay text.
146
- // options.mediaTextOnly is set by the batch dispatcher (child reads are
147
- // assembled into a flat string), so an image content-block object would
148
- // stringify to "[object Object]". Skip the image fast-path in that context
149
- // and let the file fall through to the normal text/binary read, which
150
- // emits a string. Scalar reads (no mediaTextOnly) get the rich image block.
144
+ // Read is shim-blocked, so this is the only image-view path. Batch children
145
+ // opt into this rich path for full image reads and the parent flattens each
146
+ // child's content parts into one structured result. mediaTextOnly remains
147
+ // available for callers that explicitly require a flat text result.
151
148
  if (options?.mediaTextOnly !== true && typeof args.path === 'string' && imageMimeForPath(args.path)) {
152
149
  const _imgNorm = normalizeInputPath(args.path);
153
150
  // W1 H: device-file / UNC / Windows-device / ADS guards must run
@@ -325,13 +322,17 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
325
322
  const readEntry = _diskWin
326
323
  ? { ...entry, offset: _diskWin.offset, limit: _diskWin.limit }
327
324
  : entry;
328
- // mediaTextOnly: a batch aggregate is assembled as a flat
329
- // string (String(r.body) + join), so media branches must
330
- // return text (pdf-parse text / notebook text), never a
331
- // document/image content-block object that would stringify to
332
- // "[object Object]" and drop the payload. Scalar reads (the
333
- // single-file path below) keep the rich block shapes.
334
- const body = await executeChildBuiltinTool('read', readEntry, workDir, { suppressReadUnchangedStub: true, mediaTextOnly: true, _skipReachPreflight: true });
325
+ // Full image children retain their rich blocks; the aggregate
326
+ // assembler below flattens them without stringification.
327
+ // Other media (PDF/notebook) remains text-only in a batch so
328
+ // its existing per-entry rendering contract is unchanged.
329
+ const richImage = (!readEntry.mode || readEntry.mode === 'full')
330
+ && !!imageMimeForPath(readEntry.path);
331
+ const body = await executeChildBuiltinTool('read', readEntry, workDir, {
332
+ suppressReadUnchangedStub: true,
333
+ mediaTextOnly: !richImage,
334
+ _skipReachPreflight: true,
335
+ });
335
336
  results[index] = { path: entry.path, mode: entry.mode || 'full', n: entry.n, body };
336
337
  };
337
338
  const key = entry.path || `#missing-${index}`;
@@ -363,7 +364,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
363
364
  const origLimit = typeof orig._origLimit === 'number'
364
365
  ? orig._origLimit
365
366
  : 2000;
366
- const body = needsSlice
367
+ const body = needsSlice && typeof r.body === 'string'
367
368
  ? sliceReadBodyByLines(r.body, origOffset, origLimit)
368
369
  : r.body;
369
370
  return { ...r, mode: orig.mode || 'full', n: orig.n, body };
@@ -411,6 +412,24 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
411
412
  });
412
413
  }
413
414
  }
415
+ const richPartsFor = (value) => (
416
+ value
417
+ && typeof value === 'object'
418
+ && !Array.isArray(value)
419
+ && Array.isArray(value.content)
420
+ ) ? value.content : null;
421
+ const bodyTextFor = (value) => {
422
+ const parts = richPartsFor(value);
423
+ if (!parts) return String(value || '');
424
+ return parts
425
+ .map((part) => (part && typeof part === 'object' && typeof part.text === 'string') ? part.text : '')
426
+ .filter(Boolean)
427
+ .join('\n');
428
+ };
429
+ const bodyFailed = (value) => (
430
+ value?.isError === true
431
+ || classifyResultKind(bodyTextFor(value)) === 'error'
432
+ );
414
433
  // Header path → forward slash; error bodies already normalised
415
434
  // inside the read case's catch blocks. When `read` emitted a
416
435
  // smart-cap marker, surface the truncation state in the header
@@ -423,14 +442,14 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
423
442
  }
424
443
  }
425
444
  const summaryLine = summaries.length ? ` ${summaries.join('; ')}` : '';
426
- const failedReads = orderedResults.filter((r) => classifyResultKind(String(r.body || '')) === 'error').length;
445
+ const failedReads = orderedResults.filter((r) => bodyFailed(r.body)).length;
427
446
  // reject_partial:true — when the caller asked for all-or-none,
428
447
  // refuse to return a mixed payload that downstream parsers
429
448
  // would have to disambiguate per-entry.
430
449
  if (failedReads > 0 && args.reject_partial === true) {
431
450
  const reasons = orderedResults
432
- .filter((r) => classifyResultKind(String(r.body || '')) === 'error')
433
- .map((r) => `${normalizeOutputPath(r.path)}: ${String(r.body || '').split('\n')[0]}`)
451
+ .filter((r) => bodyFailed(r.body))
452
+ .map((r) => `${normalizeOutputPath(r.path)}: ${bodyTextFor(r.body).split('\n')[0] || 'structured media read failed'}`)
434
453
  .join('; ');
435
454
  return `Error: batch read rejected (${failedReads} of ${orderedResults.length} failed; reject_partial:true) — ${reasons}`;
436
455
  }
@@ -442,6 +461,47 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
442
461
  const header = failedReads > 0
443
462
  ? `read ${orderedResults.length} (${failedReads} failed)${summaryLine}`
444
463
  : `read ${orderedResults.length}${summaryLine}`;
464
+ const hasRichBodies = orderedResults.some((r) => richPartsFor(r.body) !== null);
465
+ if (hasRichBodies) {
466
+ const content = [{ type: 'text', text: header }];
467
+ const seenTextEntryBody = new Map();
468
+ const seenRichEntryBody = new WeakMap();
469
+ for (let i = 0; i < orderedResults.length; i++) {
470
+ const r = orderedResults[i];
471
+ const path = normalizeOutputPath(r.path);
472
+ const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
473
+ const status = bodyFailed(r.body) ? 'error' : 'ok';
474
+ const textBody = bodyTextFor(r.body);
475
+ const match = /\[TRUNCATED (?:—|-) file is (\d+) lines \/ (\d+) KB\./.exec(textBody);
476
+ const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
477
+ const entryHeader = `${path} [${mode}] [${status}]${suffix}`;
478
+ const richParts = richPartsFor(r.body);
479
+ let priorIdx;
480
+ if (richParts) {
481
+ let seenByKey = seenRichEntryBody.get(r.body);
482
+ if (!seenByKey) {
483
+ seenByKey = new Map();
484
+ seenRichEntryBody.set(r.body, seenByKey);
485
+ }
486
+ const key = JSON.stringify([path, mode]);
487
+ priorIdx = seenByKey.get(key);
488
+ if (priorIdx === undefined) seenByKey.set(key, i);
489
+ } else {
490
+ const key = JSON.stringify([path, mode, r.body || '']);
491
+ priorIdx = seenTextEntryBody.get(key);
492
+ if (priorIdx === undefined) seenTextEntryBody.set(key, i);
493
+ }
494
+ if (priorIdx !== undefined) {
495
+ content.push({ type: 'text', text: `${entryHeader} [= entry #${priorIdx + 1}, identical result omitted]` });
496
+ continue;
497
+ }
498
+ content.push({ type: 'text', text: entryHeader });
499
+ if (richParts) content.push(...richParts);
500
+ else content.push({ type: 'text', text: String(r.body || '') });
501
+ }
502
+ if (args._batchCapNote) content.push({ type: 'text', text: args._batchCapNote });
503
+ return { content };
504
+ }
445
505
  // Identical-entry dedup: when the caller puts the exact same window
446
506
  // twice in the path array, coalesceObjectReadEntries already merges
447
507
  // the disk read, but the 1:1 request/response contract still renders
@@ -49,11 +49,8 @@ import {
49
49
  import { runRg, runRgWindowedLines, rgSupportsPcre2 } from './rg-runner.mjs';
50
50
  import { markScopedCacheIncomplete } from '../../session/cache/scoped-cache-outcome.mjs';
51
51
  import {
52
- groupGrepContentByFile,
53
52
  normalizeGrepLine,
54
53
  splitGrepCountPrefix,
55
- splitGrepLinePrefix,
56
- splitGrepLineNumberOnlyPrefix,
57
54
  } from './grep-formatting.mjs';
58
55
  import {
59
56
  cacheGet,
@@ -95,148 +92,16 @@ const GREP_AUTO_CONTEXT_LINES = 25;
95
92
  // each into its own --glob. Brace patterns ("*.{ts,tsx}") are left intact so
96
93
  // their internal commas are not torn apart.
97
94
 
98
- function grepMissingPatternMessage() {
99
- return 'Error: grep requires pattern.';
100
- }
101
-
102
- function globMissingPatternMessage() {
103
- return 'Error: glob requires pattern.';
104
- }
105
-
106
- // --- context-mode match-block windowing (Parts 2 & 3) ---------------------
107
- // In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
108
- // and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
109
- // head+tail slice with a middle marker instead of dropping the tail.
110
- function grepBlockMatchAnchor(line, filenameOmitted) {
111
- if (filenameOmitted) {
112
- const p = splitGrepLineNumberOnlyPrefix(line);
113
- return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
114
- }
115
- const s = splitGrepLinePrefix(line);
116
- return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
117
- }
118
-
119
- function parseGrepContextBlocks(lines, filenameOmitted) {
120
- const blocks = [];
121
- let pending = [];
122
- let i = 0;
123
- while (i < lines.length) {
124
- const line = lines[i];
125
- if (line === '--') { pending = []; i++; continue; }
126
- const anchor = grepBlockMatchAnchor(line, filenameOmitted);
127
- if (anchor) {
128
- const blockLines = pending.concat([line]);
129
- pending = [];
130
- i++;
131
- while (i < lines.length) {
132
- const next = lines[i];
133
- if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
134
- blockLines.push(next);
135
- i++;
136
- }
137
- blocks.push({ anchor, lines: blockLines });
138
- continue;
139
- }
140
- pending.push(line);
141
- i++;
142
- }
143
- return blocks;
144
- }
145
-
146
- function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
147
- const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
148
- const blocks = parseGrepContextBlocks(norm, filenameOmitted);
149
- const total = blocks.length;
150
- if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
151
- // Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
152
- // read (stdout cap / stream cap) it is a lower bound, so print ">=T".
153
- const totalStr = totalKnown ? `${total}` : `>=${total}`;
154
- const afterOffset = offset > 0 ? blocks.slice(offset) : blocks;
155
- if (afterOffset.length === 0) {
156
- // On a partial stream (line cap / timeout) the parsed blocks are a
157
- // lower bound — an offset beyond them is NOT proven past the last
158
- // match, so steer toward narrowing instead of claiming "past end".
159
- const text = totalKnown
160
- ? `[Showing 0 of ${totalStr} matches; offset ${offset} past end]`
161
- : `[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.]`;
162
- return { text, total, shown: 0, omitted: 0 };
163
- }
164
- const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
165
- const omitted = afterOffset.length - shown;
166
- const render = (arr) => arr.map((b) => b.lines.join('\n'));
167
- let segments;
168
- let nextOffset = offset + shown;
169
- if (omitted > 0 && shown > 0) {
170
- // Keep head + tail so both ends of the match range stay visible.
171
- const headCount = Math.max(1, Math.ceil(shown / 2));
172
- const tailCount = shown - headCount;
173
- const head = render(afterOffset.slice(0, headCount));
174
- const tail = tailCount > 0 ? render(afterOffset.slice(afterOffset.length - tailCount)) : [];
175
- segments = [...head, `…${omitted} matches omitted…`, ...tail];
176
- // Paging must resume at the first OMITTED block (right after the head
177
- // slice): offset+shown would permanently skip the middle blocks that
178
- // the tail slice displaced. Tail blocks re-appear on later pages —
179
- // duplication is acceptable, silent loss is not.
180
- nextOffset = offset + headCount;
181
- } else {
182
- segments = render(afterOffset.slice(0, shown));
183
- }
184
- const notice = (omitted > 0 || !totalKnown)
185
- ? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
186
- : '';
187
- return { text: segments.join('\n--\n') + notice, total, shown, omitted };
188
- }
189
-
190
- // Part 1: drop path:line match lines already emitted by an earlier pattern in
191
- // a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
192
- function dedupeFanoutMatchLines(body, seen) {
193
- const text = String(body);
194
- if (/^Error:/.test(text)) return text;
195
- const out = [];
196
- for (const line of text.split('\n')) {
197
- const s = splitGrepLinePrefix(line);
198
- if (s && s.delimiter === ':') {
199
- const key = `${s.path}\0${s.lineNo}`;
200
- if (seen.has(key)) continue;
201
- seen.add(key);
202
- }
203
- out.push(line);
204
- }
205
- return out.join('\n');
206
- }
207
-
208
- 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 }) {
209
- const lines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
210
- const normalized = lines.map((line) => relativeGrepLine(line, workDir, outputMode === 'files_with_matches', outputMode, filenameOmitted));
211
- const remaining = Math.max(0, totalWindowed - lines.length);
212
- const shown = lines.length;
213
- // Finding 3: PRE-offset grand total so the denominator matches the
214
- // context-mode notice (offset==0 leaves this unchanged).
215
- const total = offset + totalWindowed;
216
- const scopePath = JSON.stringify(normalizeOutputPath(searchPath));
217
- const truncated = (remaining > 0 || !totalKnown)
218
- ? (totalKnown
219
- ? `\n[Showing ${shown} of ${total} results; pass offset:${offset + shown} for more]`
220
- : `\n[Showing ${shown} (more matches exist — use output_mode:'count' for the exact total on ${scopePath}); pass offset:${offset + shown} for more]`)
221
- : '';
95
+ // Grep output rendering (context-block windowing, fan-out dedupe, notices)
96
+ // lives in lib/grep-output.mjs.
97
+ import {
98
+ dedupeFanoutMatchLines,
99
+ formatGrepContextOutput,
100
+ formatGrepOutput,
101
+ globMissingPatternMessage,
102
+ grepMissingPatternMessage,
103
+ } from './lib/grep-output.mjs';
222
104
 
223
- let countSummary = '';
224
- if (outputMode === 'count') {
225
- let totalMatches = 0;
226
- let fileCount = 0;
227
- for (const line of normalized) {
228
- const m = line.match(/(?:^|:)(\d+)$/);
229
- if (m) { totalMatches += Number(m[1]); fileCount++; }
230
- }
231
- countSummary = `\n[total ${totalMatches} match${totalMatches === 1 ? '' : 'es'} across ${fileCount} file${fileCount === 1 ? '' : 's'}]`;
232
- }
233
- const hasContext = (beforeN > 0 || afterN > 0 || contextN > 0);
234
- const groupedBody = (outputMode === 'content' && !hasContext && !filenameOmitted && !disableContentGrouping)
235
- ? groupGrepContentByFile(normalized)
236
- : normalized.join('\n');
237
- const body = groupedBody + truncated + countSummary;
238
- return `${prefix}${body}`;
239
- }
240
105
 
241
106
  export async function executeGrepTool(args, workDir, executeChildBuiltinTool, readStateScope = null, options = {}) {
242
107
  args = normalizeGrepArgs(args);
@@ -46,10 +46,14 @@ export function trimShellJobSpill(detail) {
46
46
  // stat/unlink keeps the main event loop free; fire-and-forget so the
47
47
  // synchronous caller receives `dir` immediately.
48
48
  const SHELL_JOB_STALE_MS = 24 * 60 * 60 * 1000;
49
- let shellJobsSwept = false;
49
+ // The sweep used to run ONCE per process. A desktop/CLI session that stays up
50
+ // for days therefore never reclaimed anything after boot; re-arm it on an
51
+ // interval so long-lived hosts keep collecting.
52
+ const SHELL_JOB_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
53
+ let shellJobsSweptAt = 0;
50
54
  async function sweepStaleShellJobs(dir) {
51
- if (shellJobsSwept) return;
52
- shellJobsSwept = true;
55
+ if (Date.now() - shellJobsSweptAt < SHELL_JOB_SWEEP_INTERVAL_MS) return;
56
+ shellJobsSweptAt = Date.now();
53
57
  const cutoff = Date.now() - SHELL_JOB_STALE_MS;
54
58
  let names;
55
59
  try { names = await fsPromises.readdir(dir); } catch { return; }
@@ -457,15 +457,52 @@ function scoreSimilarPatchLine(candidate, target) {
457
457
  return score;
458
458
  }
459
459
 
460
- export function nearestPatchLineHint(sourceLines, expectedLine, preferredLine) {
460
+ // Best fuzzy candidate for one patch line, as a structured match so callers can
461
+ // both describe it AND centre a source excerpt on it.
462
+ export function nearestPatchLineMatch(sourceLines, expectedLine, preferredLine) {
461
463
  const expected = String(expectedLine || '');
462
- if (!expected.trim()) return '';
464
+ if (!expected.trim()) return null;
463
465
  let best = null;
464
466
  const preferred = Number.isFinite(preferredLine) && preferredLine >= 0 ? preferredLine : 0;
465
467
  for (let i = 0; i < sourceLines.length; i++) {
466
468
  const score = scoreSimilarPatchLine(sourceLines[i], expected) - (Math.abs(i - preferred) * 0.01);
467
469
  if (!best || score > best.score) best = { score, index: i, line: sourceLines[i] };
468
470
  }
469
- if (!best || best.score <= 0) return '';
471
+ if (!best || best.score <= 0) return null;
472
+ return best;
473
+ }
474
+
475
+ export function nearestPatchLineHint(sourceLines, expectedLine, preferredLine) {
476
+ const best = nearestPatchLineMatch(sourceLines, expectedLine, preferredLine);
477
+ if (!best) return '';
470
478
  return `nearest line ${best.index + 1}: ${JSON.stringify(compactPatchPreviewLine(best.line))}`;
471
479
  }
480
+
481
+ // Bounded verbatim window of the CURRENT file around a failed match, so a
482
+ // context-miss retry can be built from the error alone instead of costing an
483
+ // extra read turn. Hard caps keep the added context small (<= ~1 KB).
484
+ const PATCH_EXCERPT_MAX_LINES = 14;
485
+ const PATCH_EXCERPT_MAX_CHARS = 1000;
486
+
487
+ export function formatPatchSourceExcerpt(sourceLines, centerIdx, spanLen = 1) {
488
+ const lines = sourceLines || [];
489
+ if (!lines.length) return '';
490
+ if (!Number.isFinite(centerIdx) || centerIdx < 0) return '';
491
+ const want = Math.max(6, Math.min(PATCH_EXCERPT_MAX_LINES, (Number(spanLen) || 1) + 4));
492
+ const before = Math.min(3, Math.floor(want / 3));
493
+ const start = Math.max(0, Math.min(centerIdx - before, Math.max(0, lines.length - want)));
494
+ const end = Math.min(lines.length, start + want);
495
+ const out = [];
496
+ let chars = 0;
497
+ for (let i = start; i < end; i++) {
498
+ const text = `${String(i + 1).padStart(5, ' ')}| ${compactPatchPreviewLine(lines[i], 120)}`;
499
+ chars += text.length + 1;
500
+ if (chars > PATCH_EXCERPT_MAX_CHARS) {
501
+ out.push(' | …');
502
+ break;
503
+ }
504
+ out.push(text);
505
+ }
506
+ if (!out.length) return '';
507
+ return `\ncurrent file lines ${start + 1}-${start + out.length} (verbatim — build the retry from these):\n${out.join('\n')}`;
508
+ }
@@ -30,6 +30,8 @@ import {
30
30
  splitTextLinesForPatch,
31
31
  firstMeaningfulPatchLine,
32
32
  nearestPatchLineHint,
33
+ nearestPatchLineMatch,
34
+ formatPatchSourceExcerpt,
33
35
  compactPatchPreviewLine,
34
36
  decodeValidUtf8OrNull,
35
37
  } from './matcher.mjs';
@@ -88,25 +90,36 @@ function formatV4AAnchorMissHint(sourceLines, hunk) {
88
90
  function formatV4AContextMissHint(sourceLines, stats, anchorLine) {
89
91
  const expected = firstMeaningfulPatchLine(stats.oldLines);
90
92
  const parts = [];
93
+ let centerIdx = -1;
91
94
  if (expected) {
92
95
  const nearest = nearestPatchLineHint(sourceLines, expected, anchorLine);
93
96
  parts.push(`expected first old line: ${JSON.stringify(compactPatchPreviewLine(expected))}`);
94
97
  if (nearest) parts.push(nearest);
95
98
  const divergence = firstV4ADivergenceHint(sourceLines, stats.oldLines, anchorLine);
96
- if (divergence) parts.push(divergence);
99
+ if (divergence) {
100
+ parts.push(divergence.text);
101
+ centerIdx = divergence.fileIdx;
102
+ } else {
103
+ const match = nearestPatchLineMatch(sourceLines, expected, anchorLine);
104
+ if (match) centerIdx = match.index;
105
+ }
97
106
  }
107
+ if (centerIdx < 0 && Number.isFinite(anchorLine) && anchorLine >= 0) centerIdx = anchorLine;
98
108
  parts.push('use exact current context or a broader @@ anchor; no stubs.');
99
- return ` ${parts.join('; ')}`;
109
+ const head = ` ${parts.join('; ')} Copy the context lines verbatim from the excerpt below — do not retype them from memory.`;
110
+ return `${head}${formatPatchSourceExcerpt(sourceLines, centerIdx, (stats.oldLines || []).length)}`;
100
111
  }
101
112
 
102
113
  // When the FIRST old line does exist verbatim in the source, the real
103
114
  // mismatch is some later line of the block — name it, with both sides
104
115
  // JSON-escaped so invisible differences (real char vs literal \uXXXX
105
- // escape, tabs, trailing spaces) become visible in the error.
116
+ // escape, tabs, trailing spaces) become visible in the error. Returns the
117
+ // message plus the file index it points at, so the caller can centre the
118
+ // source excerpt on the true divergence instead of the anchor.
106
119
  function firstV4ADivergenceHint(sourceLines, oldLines, anchorLine) {
107
120
  const lines = oldLines || [];
108
121
  const firstIdx = lines.findIndex((l) => String(l ?? '').trim().length > 0);
109
- if (firstIdx < 0) return '';
122
+ if (firstIdx < 0) return null;
110
123
  const first = lines[firstIdx];
111
124
  const starts = [];
112
125
  for (let i = 0; i < sourceLines.length; i++) {
@@ -115,16 +128,19 @@ function firstV4ADivergenceHint(sourceLines, oldLines, anchorLine) {
115
128
  const pref = Number.isFinite(anchorLine) && anchorLine >= 0 ? anchorLine : 0;
116
129
  const start = starts.filter((s) => s >= 0)
117
130
  .sort((a, b) => Math.abs(a - pref) - Math.abs(b - pref) || a - b)[0];
118
- if (start === undefined) return '';
131
+ if (start === undefined) return null;
119
132
  for (let k = 0; k < lines.length; k++) {
120
133
  const exp = lines[k];
121
134
  const act = sourceLines[start + k];
122
135
  if (act !== exp) {
123
136
  const actText = act === undefined ? '(past EOF)' : JSON.stringify(compactPatchPreviewLine(act));
124
- return `first divergent line: old[${k + 1}] expected ${JSON.stringify(compactPatchPreviewLine(exp))} vs file line ${start + k + 1} actual ${actText}`;
137
+ return {
138
+ text: `first divergent line: old[${k + 1}] expected ${JSON.stringify(compactPatchPreviewLine(exp))} vs file line ${start + k + 1} actual ${actText}`,
139
+ fileIdx: start + k,
140
+ };
125
141
  }
126
142
  }
127
- return '';
143
+ return null;
128
144
  }
129
145
 
130
146
  function joinTextLinesForPatch(lines) {
@@ -205,7 +221,7 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
205
221
  }
206
222
  }
207
223
  if (oldStartIdx < 0) {
208
- const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)} Copy context lines verbatim from the latest read output — do not retype them from memory.`;
224
+ const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}`;
209
225
  return { error: msg };
210
226
  }
211
227
  const matchLen = stats.oldCount === 0 ? 0 : oldLinesPattern.length;
@@ -477,7 +477,14 @@ async function reloadRuntimeConfig() {
477
477
  getConfig().interactive ?? [],
478
478
  // Single resolved main-channel id used for the schedule `channel` flag.
479
479
  getConfig().channelId,
480
- { restart: getBridgeRuntimeConnected() }
480
+ // The scheduler must be RE-ARMED by the reload whenever it is supposed to
481
+ // be running. `restart: false` only destroys the cron/one-shot bindings and
482
+ // hands lifecycle back to the caller — but on an automation-only install
483
+ // (no messaging backend) startAutomationRuntime() is already past its
484
+ // `automationRunning` guard, so nobody would ever call scheduler.start()
485
+ // again and every saved/edited schedule stayed silently disarmed until the
486
+ // daemon restarted.
487
+ { restart: automationRunning || getBridgeRuntimeConnected() }
481
488
  );
482
489
  const nextBackend = createBackend(getConfig());
483
490
  const backendTypeChanged = (nextBackend?.name || "") !== previousBackendName;