mixdog 0.9.15 → 0.9.16

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 (125) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench/cache-probe-tasks.json +1 -1
  3. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  4. package/scripts/bench/r4-mixed-tasks.json +1 -1
  5. package/scripts/bench/review-tasks.json +1 -1
  6. package/scripts/build-runtime-windows.ps1 +242 -242
  7. package/scripts/explore-bench.mjs +5 -4
  8. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  9. package/scripts/output-style-smoke.mjs +3 -3
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/termio-input-smoke.mjs +199 -0
  13. package/scripts/tool-efficiency-diag.mjs +88 -0
  14. package/scripts/tool-smoke.mjs +40 -24
  15. package/scripts/tui-frame-harness-shim.mjs +32 -0
  16. package/scripts/tui-frame-harness.mjs +306 -0
  17. package/src/agents/heavy-worker/AGENT.md +6 -7
  18. package/src/agents/worker/AGENT.md +6 -7
  19. package/src/lib/keychain-cjs.cjs +28 -11
  20. package/src/lib/rules-builder.cjs +6 -10
  21. package/src/mixdog-session-runtime.mjs +90 -20
  22. package/src/output-styles/simple.md +22 -24
  23. package/src/rules/agent/00-core.md +12 -11
  24. package/src/rules/agent/30-explorer.md +22 -21
  25. package/src/rules/lead/01-general.md +8 -8
  26. package/src/rules/lead/02-channels.md +3 -3
  27. package/src/rules/lead/lead-brief.md +11 -12
  28. package/src/rules/shared/01-tool.md +25 -14
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
  33. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  34. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  40. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  41. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  44. package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
  45. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  46. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  47. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  48. package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  50. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  51. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  54. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
  56. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  74. package/src/runtime/memory/index.mjs +95 -8
  75. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  76. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  77. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  78. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  79. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  80. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  81. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  82. package/src/runtime/memory/lib/memory.mjs +88 -1
  83. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  84. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  86. package/src/runtime/shared/child-guardian.mjs +4 -2
  87. package/src/runtime/shared/open-url.mjs +2 -1
  88. package/src/runtime/shared/singleton-owner.mjs +26 -0
  89. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  90. package/src/runtime/shared/transcript-writer.mjs +3 -1
  91. package/src/session-runtime/config-helpers.mjs +7 -2
  92. package/src/session-runtime/cwd-plugins.mjs +6 -1
  93. package/src/session-runtime/effort.mjs +6 -1
  94. package/src/session-runtime/plugin-mcp.mjs +116 -12
  95. package/src/session-runtime/settings-api.mjs +7 -1
  96. package/src/standalone/channel-worker.mjs +25 -3
  97. package/src/standalone/explore-tool.mjs +5 -5
  98. package/src/standalone/hook-bus/config.mjs +38 -2
  99. package/src/standalone/hook-bus/handlers.mjs +103 -11
  100. package/src/standalone/hook-bus.mjs +20 -6
  101. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  102. package/src/tui/App.jsx +214 -30
  103. package/src/tui/app/core-memory-picker.mjs +6 -6
  104. package/src/tui/app/model-options.mjs +10 -4
  105. package/src/tui/app/settings-picker.mjs +5 -30
  106. package/src/tui/app/slash-commands.mjs +0 -1
  107. package/src/tui/app/slash-dispatch.mjs +0 -16
  108. package/src/tui/app/text-layout.mjs +57 -0
  109. package/src/tui/app/transcript-window.mjs +53 -2
  110. package/src/tui/app/use-mouse-input.mjs +60 -47
  111. package/src/tui/app/use-transcript-window.mjs +38 -10
  112. package/src/tui/components/PromptInput.jsx +18 -1
  113. package/src/tui/components/TextEntryPanel.jsx +97 -33
  114. package/src/tui/dist/index.mjs +487 -211
  115. package/src/tui/engine/tool-card-results.mjs +22 -5
  116. package/src/tui/engine.mjs +35 -5
  117. package/src/tui/index.jsx +4 -1
  118. package/src/workflows/default/WORKFLOW.md +27 -31
  119. package/vendor/ink/build/components/App.js +62 -17
  120. package/vendor/ink/build/ink.js +78 -3
  121. package/vendor/ink/build/input-parser.d.ts +9 -4
  122. package/vendor/ink/build/input-parser.js +45 -176
  123. package/vendor/ink/build/log-update.js +47 -2
  124. package/vendor/ink/build/termio-keypress.js +240 -0
  125. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -4,8 +4,8 @@
4
4
  * thin executor; search-tool.mjs re-exports these for unchanged importers.
5
5
  */
6
6
  import { statSync } from 'fs';
7
- import { isAbsolute, resolve } from 'path';
8
- import { findBySuffixStrip, findFileByBasename } from './path-diagnostics.mjs';
7
+ import { basename, isAbsolute, join, resolve } from 'path';
8
+ import { findBySuffixStrip, findDirectoryByBasename, findFileByBasename, listSiblings } from './path-diagnostics.mjs';
9
9
  import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
10
10
 
11
11
  // Deterministic ENOENT recovery: when a grep path does not exist, surface
@@ -64,12 +64,64 @@ export function stripEmbeddedPathQuotes(p) {
64
64
  // not-found codes: EACCES/EPERM etc. keep their real failure semantics and
65
65
  // must not trigger same-basename guidance or the BFS scan.
66
66
  const NOT_FOUND_CODES = new Set(['ENOENT', 'ENOTDIR']);
67
+
68
+ export const ENOENT_FIND_NUDGE = 'Locate with find on the basename before retrying.';
69
+
70
+ function isDirectoryPathGuess(missingPath) {
71
+ const base = basename(String(missingPath || '').replace(/\\/g, '/'));
72
+ if (!base || /[*?[\]{}]/.test(base)) return false;
73
+ if (/\.[a-zA-Z0-9]{1,12}$/.test(base)) return false;
74
+ return true;
75
+ }
76
+
77
+ function nearestExistingParentRel(workDir, missingPath) {
78
+ try {
79
+ const segments = String(missingPath).replace(/\\/g, '/').split('/')
80
+ .filter((s) => s && s !== '.' && s !== '..' && !/^[A-Za-z]:$/.test(s));
81
+ for (let i = segments.length - 1; i > 0; i--) {
82
+ const candidate = join(workDir, ...segments.slice(0, i));
83
+ try {
84
+ if (statSync(candidate).isDirectory()) {
85
+ return segments.slice(0, i).join('/');
86
+ }
87
+ } catch { /* keep walking up */ }
88
+ }
89
+ if (statSync(workDir).isDirectory()) return '.';
90
+ } catch { /* fall through */ }
91
+ return null;
92
+ }
93
+
94
+ export function spaceJoinedPathHint(requestedPath) {
95
+ if (typeof requestedPath !== 'string') return '';
96
+ const trimmed = requestedPath.trim();
97
+ if (!/\s/.test(trimmed)) return '';
98
+ const segments = trimmed.split(/\s+/).filter(Boolean);
99
+ if (segments.length < 2) return '';
100
+ return ' Pass multiple scopes as path[] array (not a space-joined string).';
101
+ }
102
+
103
+ export function appendEnoentFindNudge(text = '') {
104
+ const base = String(text || '');
105
+ if (base.includes(ENOENT_FIND_NUDGE)) return base;
106
+ const sep = base.length && !/\s$/.test(base) ? ' ' : '';
107
+ return `${base}${sep}${ENOENT_FIND_NUDGE}`;
108
+ }
109
+
110
+ export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENOENT') {
111
+ if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return String(hint || '');
112
+ return appendEnoentFindNudge(String(hint || '') + spaceJoinedPathHint(requestedPath));
113
+ }
114
+
67
115
  export function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT') {
68
116
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
69
117
  const suffixHit = findBySuffixStrip(workDir, missingPath);
70
118
  if (suffixHit) return suffixHit;
71
119
  const elsewhere = findFileByBasename(workDir, missingPath);
72
120
  if (elsewhere.length === 1) return elsewhere[0];
121
+ if (isDirectoryPathGuess(missingPath)) {
122
+ const dirHits = findDirectoryByBasename(workDir, missingPath, { limit: 3 });
123
+ if (dirHits.length === 1) return dirHits[0];
124
+ }
73
125
  return null;
74
126
  }
75
127
 
@@ -100,6 +152,24 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
100
152
  if (elsewhere.length) {
101
153
  return ` Not found at this path; the same filename exists at: ${elsewhere.map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. ${actionVerb} that path directly.`;
102
154
  }
155
+ if (isDirectoryPathGuess(missingPath)) {
156
+ const dirHits = findDirectoryByBasename(workDir, missingPath, { limit: 5 });
157
+ if (dirHits.length > 1) {
158
+ return ` Not found at this path; same-named directories exist at: ${dirHits.slice(0, 5).map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. ${actionVerb} one of those paths directly.`;
159
+ }
160
+ if (dirHits.length === 1) {
161
+ return ` Not found at this path; the same directory name exists at: "${normalizeOutputPath(dirHits[0])}". ${actionVerb} that path directly.`;
162
+ }
163
+ const parentRel = nearestExistingParentRel(workDir, missingPath);
164
+ if (parentRel) {
165
+ const resolvedParent = parentRel === '.' ? workDir : resolveAgainstCwd(parentRel, workDir);
166
+ const siblings = listSiblings(resolvedParent, 5);
167
+ if (siblings.length) {
168
+ const shown = parentRel === '.' ? '.' : normalizeOutputPath(parentRel);
169
+ return ` Not found at this path; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}.`;
170
+ }
171
+ }
172
+ }
103
173
  return '';
104
174
  }
105
175
 
@@ -146,7 +216,7 @@ export function basePathDiagnostic(basePaths, workDir) {
146
216
  return `${normalizeOutputPath(basePath)}: ${st.isDirectory() ? 'path exists (dir)' : 'path exists (file)'}`;
147
217
  } catch (err) {
148
218
  return `${normalizeOutputPath(basePath)}: path does not exist (${err?.code || 'ENOENT'})`
149
- + buildNotFoundHint(workDir, resolved, 'Search', err?.code);
219
+ + finalizeReadFamilyEnoentTail(buildNotFoundHint(workDir, resolved, 'Search', err?.code), basePath, err?.code);
150
220
  }
151
221
  }).join('; ');
152
222
  }
@@ -18,6 +18,7 @@ import {
18
18
  _suggestIndexedPaths,
19
19
  basePathDiagnostic,
20
20
  buildNotFoundHint,
21
+ finalizeReadFamilyEnoentTail,
21
22
  tryReadFamilyEnoentRedirect,
22
23
  isUncOrSmbPath,
23
24
  relativePathPrefix,
@@ -431,7 +432,13 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
431
432
  const rawPatterns = Array.isArray(rawPattern)
432
433
  ? rawPattern.filter(p => typeof p === 'string' && p)
433
434
  : (rawPattern ? (expandLegacyEscapedAlternationPattern(String(rawPattern)) || [String(rawPattern)]) : []);
434
- const patterns = uniqueStrings(rawPatterns.map(normalizeSearchPattern));
435
+ let patterns = uniqueStrings(rawPatterns.map(normalizeSearchPattern));
436
+ const GREP_PATTERN_ARRAY_CAP = 10;
437
+ let patternCapNote = '';
438
+ if (patterns.length > GREP_PATTERN_ARRAY_CAP) {
439
+ patternCapNote = `[capped at ${GREP_PATTERN_ARRAY_CAP} of ${patterns.length} patterns]\n`;
440
+ patterns = patterns.slice(0, GREP_PATTERN_ARRAY_CAP);
441
+ }
435
442
  if (patterns.length === 0) {
436
443
  if (args.glob || hasGlobMagic(args.path)) {
437
444
  const globArgs = {
@@ -646,7 +653,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
646
653
  if (!windowed.length) {
647
654
  const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
648
655
  const globStr = normalizedGlobPatterns.length > 0 ? ` glob=${JSON.stringify(normalizedGlobPatterns)}` : '';
649
- return `${chunkPrefix}(no matches) pattern=${patternStr} path=${searchPath}${globStr}`;
656
+ return `${patternCapNote}${chunkPrefix}(no matches) pattern=${patternStr} path=${searchPath}${globStr}`;
650
657
  }
651
658
  return formatGrepOutput({
652
659
  windowed,
@@ -665,7 +672,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
665
672
  globPatterns: normalizedGlobPatterns,
666
673
  fileType,
667
674
  filenameOmitted: false,
668
- prefix: chunkPrefix,
675
+ prefix: patternCapNote + chunkPrefix,
669
676
  });
670
677
  }
671
678
 
@@ -727,9 +734,9 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
727
734
  });
728
735
  if (redirected) return redirected;
729
736
  const msg = `Error: path does not exist: ${normalizeOutputPath(grepResolvedPath)} (${err?.code || 'ENOENT'})`;
730
- const hint = buildNotFoundHint(workDir, grepResolvedPath, 'Search', err?.code);
731
- if (hint) return msg + hint;
732
- return msg + await _suggestIndexedPaths(grepResolvedPath, executeChildBuiltinTool, workDir);
737
+ let hint = buildNotFoundHint(workDir, grepResolvedPath, 'Search', err?.code);
738
+ if (!hint) hint = await _suggestIndexedPaths(grepResolvedPath, executeChildBuiltinTool, workDir);
739
+ return msg + finalizeReadFamilyEnoentTail(hint, searchPath, err?.code);
733
740
  }
734
741
  const filenameOmitted = forceGrepFilename ? false : grepStat.isFile();
735
742
 
@@ -864,7 +871,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
864
871
  } catch { /* best-effort hint */ }
865
872
  }
866
873
  }
867
- const out = body + rgPartialSuffix;
874
+ const out = patternCapNote + body + rgPartialSuffix;
868
875
  const shownLines = headLimit === Infinity ? windowed : windowed.slice(0, headLimit);
869
876
  const remaining = Math.max(0, totalWindowed - shownLines.length);
870
877
  // Mirrors formatGrepOutput truncation / totalKnown semantics.
@@ -969,7 +976,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
969
976
  globPatterns: normalizedGlobPatterns,
970
977
  fileType,
971
978
  filenameOmitted,
972
- prefix: '[regex parse fallback: fixed-string terms]\n',
979
+ prefix: patternCapNote + '[regex parse fallback: fixed-string terms]\n',
973
980
  }) || `(no matches) fixed_terms=${JSON.stringify(fixedPatterns)} path=${searchPath}`;
974
981
  recordGrepReadSnapshot(grepStat);
975
982
  return body + rgPartialSuffix;
@@ -1037,6 +1044,12 @@ export async function executeGlobTool(args, workDir, options = {}) {
1037
1044
  if (patterns.length === 0) {
1038
1045
  patterns = ['*'];
1039
1046
  }
1047
+ const GLOB_PATTERN_ARRAY_CAP = 10;
1048
+ let globPatternCapNote = '';
1049
+ if (patterns.length > GLOB_PATTERN_ARRAY_CAP) {
1050
+ globPatternCapNote = `[capped at ${GLOB_PATTERN_ARRAY_CAP} of ${patterns.length} patterns]\n`;
1051
+ patterns = patterns.slice(0, GLOB_PATTERN_ARRAY_CAP);
1052
+ }
1040
1053
 
1041
1054
  const basePaths = (Array.isArray(args.path) && args.path.length > 0)
1042
1055
  ? args.path
@@ -1174,7 +1187,7 @@ export async function executeGlobTool(args, workDir, options = {}) {
1174
1187
  }
1175
1188
  const hint = buildNotFoundHint(workDir, rgCwd, 'Search', err?.code);
1176
1189
  return {
1177
- error: `path does not exist: ${normalizeOutputPath(rgCwd)} (${err?.code || 'ENOENT'})${hint}`,
1190
+ error: `path does not exist: ${normalizeOutputPath(rgCwd)} (${err?.code || 'ENOENT'})${finalizeReadFamilyEnoentTail(hint, root, err?.code)}`,
1178
1191
  paths: [],
1179
1192
  stdoutTruncated: false,
1180
1193
  };
@@ -1261,7 +1274,7 @@ export async function executeGlobTool(args, workDir, options = {}) {
1261
1274
  const body = capped.length > 0
1262
1275
  ? `${capped.join('\n')}${remaining > 0 ? `\n... [${remaining} more entries of ${totalBeforeOffset} total — pass offset:${offset + capped.length} to continue]` : ''}${errSuffix}`
1263
1276
  : '';
1264
- const out = body || emptyDiag || '(no files found)';
1277
+ const out = globPatternCapNote + (body || emptyDiag || '(no files found)');
1265
1278
  if (options?.scopedCacheOutcome && (accumTruncated || rgStdoutTruncated || rgStdoutPartial || remaining > 0)) {
1266
1279
  markScopedCacheIncomplete(options.scopedCacheOutcome);
1267
1280
  }
@@ -54,6 +54,29 @@ function sleep(ms) {
54
54
  const JOB_STATUS_PREVIEW_MAX_BYTES = 4096;
55
55
  const JOB_STATUS_PREVIEW_MAX_LINES = 20;
56
56
  const JOB_STATUS_PREVIEW_MAX_CHARS = 1200;
57
+ // Hard ceiling on a background job's on-disk stdout+stderr. Mirrors the
58
+ // foreground SHELL_OUTPUT_DISK_CAP (shell-command.mjs) so a runaway
59
+ // background loop is killed and flagged instead of filling the filesystem.
60
+ const SHELL_JOB_OUTPUT_DISK_CAP = 100 * 1024 * 1024;
61
+ // Poll cadence for the adopted-job output-cap self-tick (mirrors the
62
+ // foreground sizeWatchdog in shell-command.mjs).
63
+ const ADOPTED_JOB_CAP_POLL_MS = 1_000;
64
+
65
+ // Combined byte size of a job's spilled stdout/stderr files, or 0 if
66
+ // unreadable. mergeStderr collapses both onto stdoutPath, so count it once.
67
+ function shellJobOutputBytes(detail) {
68
+ let total = 0;
69
+ const seen = new Set();
70
+ for (const p of [detail?.stdoutPath, detail?.stderrPath]) {
71
+ if (!p || seen.has(p)) continue;
72
+ seen.add(p);
73
+ try {
74
+ if (existsSync(p)) total += statSync(p).size;
75
+ } catch { /* ignore */ }
76
+ }
77
+ return total;
78
+ }
79
+
57
80
  function shellQuoteSingle(s) {
58
81
  return `'${String(s).replace(/'/g, `'\"'\"'`)}'`;
59
82
  }
@@ -333,6 +356,19 @@ function refreshShellJob(jobId) {
333
356
  _unregisterLiveJobPid(detail.pid);
334
357
  return detail;
335
358
  }
359
+ // Output size watchdog: cap the job's spilled stdout/stderr at the same
360
+ // ceiling as foreground runs. Past it, SIGKILL the tree and flag the job
361
+ // so a runaway producer cannot fill the filesystem.
362
+ if (shellJobOutputBytes(detail) > SHELL_JOB_OUTPUT_DISK_CAP) {
363
+ killProcessTree(detail.pid, 'SIGKILL');
364
+ detail.status = 'failed';
365
+ detail.exitCode = 137;
366
+ detail.finishedAt = new Date().toISOString();
367
+ detail.error = `output exceeded ${SHELL_JOB_OUTPUT_DISK_CAP} byte cap`;
368
+ writeShellJobDetail(detail);
369
+ _unregisterLiveJobPid(detail.pid);
370
+ return detail;
371
+ }
336
372
  if (detail.pid && !isPidAlive(detail.pid)) {
337
373
  detail.status = 'failed';
338
374
  detail.finishedAt = new Date().toISOString();
@@ -668,10 +704,29 @@ export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStd
668
704
  if (Number.isFinite(pid) && pid > 0) {
669
705
  _installShellJobsExitHook();
670
706
  _registerLiveJobPid(pid, jobId);
707
+ // Adopted jobs have no staged wrapper timer and may have no active
708
+ // task waiter, so nothing would call refreshShellJob() to enforce the
709
+ // output cap / timeout. Drive a periodic tick until the job settles.
710
+ _armAdoptedJobCapPoll(jobId);
671
711
  }
672
712
  return { ...detail, exitPath, donePath };
673
713
  }
674
714
 
715
+ // Periodic self-tick for adopted (auto-backgrounded) jobs: refreshShellJob
716
+ // runs the SHELL_JOB_OUTPUT_DISK_CAP + timeout watchdogs. Stops once the job
717
+ // leaves 'running'. unref()'d so it never pins the host process.
718
+ function _armAdoptedJobCapPoll(jobId) {
719
+ const tick = setInterval(() => {
720
+ let detail = null;
721
+ try { detail = refreshShellJob(jobId); } catch { /* ignore */ }
722
+ if (!detail || detail.status !== 'running') {
723
+ clearInterval(tick);
724
+ }
725
+ }, ADOPTED_JOB_CAP_POLL_MS);
726
+ if (typeof tick.unref === 'function') tick.unref();
727
+ return tick;
728
+ }
729
+
675
730
  function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellArgs, shellType, clientHostPid }) {
676
731
  // Route ANY PowerShell shell to the PS wrapper, regardless of platform.
677
732
  // Gating on win32 sent shell:'powershell' on macOS/Linux down the POSIX
@@ -25,7 +25,8 @@ import {
25
25
  } from './paths.mjs';
26
26
  import { ensureNativePatchBinaryAvailable } from './native-server.mjs';
27
27
  import { assertPathReachable } from '../builtin/fs-reachability.mjs';
28
- import { dispatchNativePatch, dispatchJsPatchEntries } from './dispatch.mjs';
28
+ import { dispatchNativePatch } from './dispatch.mjs';
29
+ import { normalizeOutputPath } from '../builtin.mjs';
29
30
  import {
30
31
  planV4ARenameSections,
31
32
  applyV4ARenameSections,
@@ -40,6 +41,10 @@ function isPatchErrorText(text) {
40
41
  }
41
42
 
42
43
  const APPLY_PATCH_UI_DIFF_MAX_CHARS = 64 * 1024;
44
+ // Reject oversized patch bodies before parse / native Buffer.from
45
+ // (native-server.mjs Buffer.from(patchText)). A few MB covers any legitimate
46
+ // multi-file edit; past this it is a runaway / accidental blob.
47
+ const APPLY_PATCH_MAX_BYTES = 8 * 1024 * 1024;
43
48
  const APPLY_PATCH_UI_DIFF_REGISTRY_MAX = 64;
44
49
  const _applyPatchUiDiffByCallId = new Map();
45
50
 
@@ -107,6 +112,10 @@ async function apply_patch(args, cwd, options = {}) {
107
112
  if (isCompactedPlaceholderPatch(patchStr)) {
108
113
  throw new Error('patch body is a compacted-history placeholder ([mixdog compacted …]), not real patch content. Re-read the target file span and write a fresh patch; never resubmit compacted output as new tool input.');
109
114
  }
115
+ const patchByteLen = Buffer.byteLength(patchStr, 'utf8');
116
+ if (patchByteLen > APPLY_PATCH_MAX_BYTES) {
117
+ throw new Error(`apply_patch: patch too large (${patchByteLen} bytes > ${APPLY_PATCH_MAX_BYTES} byte cap); split into smaller patches`);
118
+ }
110
119
  const requestedFormat = String(args?.format || '').toLowerCase();
111
120
  if (requestedFormat && requestedFormat !== 'unified' && requestedFormat !== 'v4a') {
112
121
  throw new Error('apply_patch: "format" must be "unified" or "v4a"');
@@ -233,22 +242,18 @@ async function apply_patch(args, cwd, options = {}) {
233
242
  }
234
243
  const insideEntries = entries.filter((entry) => !isResolvedPathOutsideBase(entry.fullPath, basePath));
235
244
  const outsideEntries = entries.filter((entry) => isResolvedPathOutsideBase(entry.fullPath, basePath));
245
+ if (outsideEntries.length > 0) {
246
+ const rels = outsideEntries.map((e) => normalizeOutputPath(e.fullPath || e.displayPath || '(unknown)')).join(', ');
247
+ return wrapPatchMutationOutput(
248
+ `Error: apply_patch target resolves outside base path (../ or absolute not allowed): ${rels}`,
249
+ mutationPlan,
250
+ { backend: 'native-patch' },
251
+ );
252
+ }
236
253
  const parsedInside = (parsed || []).filter(
237
254
  (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
238
255
  );
239
256
  const resultParts = [];
240
- if (outsideEntries.length > 0) {
241
- const jsResult = await dispatchJsPatchEntries({
242
- rows: outsideEntries,
243
- parsed,
244
- basePath,
245
- dryRun,
246
- fuzzy,
247
- readStateScope,
248
- });
249
- if (isPatchErrorText(jsResult)) return wrapPatchMutationOutput(jsResult, mutationPlan, { backend: 'js-patch' });
250
- resultParts.push(jsResult);
251
- }
252
257
  if (insideEntries.length > 0) {
253
258
  const nativePatchStr = rewriteHeaderPaths(renderParsedUnifiedPatch(parsedInside), headerRewrites);
254
259
  const nativeResult = await dispatchNativePatch({
@@ -331,6 +336,9 @@ function maybeCapturePatchReplay(args, cwd, errorText) {
331
336
  for (const rel of rels) {
332
337
  try {
333
338
  const abs = isAbsolute(rel) ? rel : pathResolve(basePath, rel);
339
+ // Never persist snapshots for targets outside basePath — a malicious
340
+ // or malformed patch could otherwise exfiltrate arbitrary files.
341
+ if (isResolvedPathOutsideBase(abs, basePath)) { files[rel] = null; continue; }
334
342
  files[rel] = existsSync(abs) ? readFileSync(abs, 'utf8') : null;
335
343
  } catch { files[rel] = null; }
336
344
  }
@@ -45,8 +45,19 @@ export function resolveBasePath(cwd, basePath) {
45
45
  }
46
46
 
47
47
  export function isResolvedPathOutsideBase(fullPath, basePath) {
48
- const rel = pathRelative(pathResolve(basePath), pathResolve(fullPath)).replace(/\\/g, '/');
49
- if (!rel || isAbsolute(rel)) return true;
48
+ // Realpath-resolve so a symlink INSIDE base that points outside is caught:
49
+ // a lexical relative check only sees the pre-resolution path. Resolve the
50
+ // nearest existing ancestor (create-mode leaves don't exist yet) on both
51
+ // sides, then compare.
52
+ const realBase = realpathNearestExistingAncestor(pathResolve(basePath));
53
+ const realFull = realpathNearestExistingAncestor(pathResolve(fullPath));
54
+ const rel = pathRelative(realBase, realFull).replace(/\\/g, '/');
55
+ // rel === '' means realFull IS realBase — e.g. a create-mode target whose
56
+ // nearest existing ancestor is base itself, or a direct child. That is
57
+ // inside base, so pass. Only reject an absolute rel (different root/drive)
58
+ // or one that escapes via '..'.
59
+ if (rel === '') return false;
60
+ if (isAbsolute(rel)) return true;
50
61
  return rel.split(/[\\/]+/).some((part) => part === '..');
51
62
  }
52
63
 
@@ -2,7 +2,7 @@
2
2
  // conversion. Moved verbatim from patch.mjs; anchor/context matching, EOF
3
3
  // handling, rename atomicity, and conversion output are all unchanged.
4
4
 
5
- import { readFileSync, lstatSync, mkdirSync } from 'node:fs';
5
+ import { readFileSync, lstatSync, mkdirSync, realpathSync } from 'node:fs';
6
6
  import { unlink } from 'node:fs/promises';
7
7
  import { dirname as pathDirname } from 'node:path';
8
8
  import {
@@ -304,6 +304,24 @@ function v4aRenamePathKey(absPath) {
304
304
  return process.platform === 'win32' ? String(absPath || '').toLowerCase() : String(absPath || '');
305
305
  }
306
306
 
307
+ // True when src and dest point at the SAME physical file despite differing
308
+ // path strings — the case-only rename case on a case-insensitive fs (macOS,
309
+ // Windows). realpathSync collapses casing to the canonical on-disk form, so
310
+ // equal realpaths prove same-file. This is authoritative and requires BOTH
311
+ // paths to actually exist: if either realpath fails (e.g. dest missing on a
312
+ // case-SENSITIVE fs — a normal rename), the paths are NOT the same file, so
313
+ // the source must still be unlinked. Never guess "same file" from a
314
+ // lowercase string match: that false-positives on case-sensitive fs and turns
315
+ // a rename into a copy that leaks the source.
316
+ function renameTargetsSamePhysicalFile(srcFull, destFull) {
317
+ if (srcFull === destFull) return false;
318
+ try {
319
+ return realpathSync(srcFull) === realpathSync(destFull);
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+
307
325
  function v4aSpecialFileStatMessage(displayPath) {
308
326
  return `apply_patch: cannot patch special file (FIFO / character / block device / socket): ${normalizeOutputPath(displayPath)}`;
309
327
  }
@@ -319,9 +337,13 @@ function lstatV4APatchTarget(fullPath, displayPath) {
319
337
  function validateV4ARenameSection(section, basePath, seenDestKeys) {
320
338
  const srcFull = resolveV4AEntryPath(basePath, section.path);
321
339
  const destFull = resolveV4AEntryPath(basePath, section.movePath);
322
- if (v4aRenamePathKey(srcFull) === v4aRenamePathKey(destFull)) {
340
+ // Case-only rename (foo.js -> Foo.js) on a case-insensitive fs resolves to
341
+ // the same key but is a legitimate rename. Reject "same path" only when the
342
+ // raw paths are byte-identical; a case-only difference falls through.
343
+ if (v4aRenamePathKey(srcFull) === v4aRenamePathKey(destFull) && srcFull === destFull) {
323
344
  return `apply_patch: V4A rename source and destination are the same path (${normalizeOutputPath(section.path)})`;
324
345
  }
346
+ const caseOnlyRename = v4aRenamePathKey(srcFull) === v4aRenamePathKey(destFull) && srcFull !== destFull;
325
347
  const destKey = v4aRenamePathKey(destFull);
326
348
  if (seenDestKeys.has(destKey)) {
327
349
  return `apply_patch: duplicate V4A rename destination ${normalizeOutputPath(section.movePath)}`;
@@ -349,6 +371,14 @@ function validateV4ARenameSection(section, basePath, seenDestKeys) {
349
371
  if (!destSt.isFile()) {
350
372
  return `apply_patch: V4A rename destination is not a regular file: ${normalizeOutputPath(section.movePath)}`;
351
373
  }
374
+ // Destination already exists. On a case-insensitive fs a case-only rename
375
+ // (foo.js -> Foo.js) resolves dest to the SAME physical file as src — that
376
+ // is the intended re-case, not a clobber, so allow it. Confirm via realpath
377
+ // (canonical path collapses case) so the guard can't false-reject. Any
378
+ // other existing destination would be clobbered by atomicWrite; refuse.
379
+ if (!caseOnlyRename && !renameTargetsSamePhysicalFile(srcFull, destFull)) {
380
+ return `apply_patch: V4A rename destination already exists: ${normalizeOutputPath(section.movePath)}; delete it first or choose a new name`;
381
+ }
352
382
  } catch (err) {
353
383
  if (err?.code !== 'ENOENT') {
354
384
  return `apply_patch: V4A rename destination unreadable: ${normalizeOutputPath(section.movePath)} (${err?.code || err?.message || String(err)})`;
@@ -363,6 +393,12 @@ function validateV4ARenameSection(section, basePath, seenDestKeys) {
363
393
  async function applyV4ARenameSection(section, basePath, options = {}) {
364
394
  const srcFull = resolveV4AEntryPath(basePath, section.path);
365
395
  const destFull = resolveV4AEntryPath(basePath, section.movePath);
396
+ // Case-only rename on a case-insensitive fs: src and dest are the SAME
397
+ // physical file. atomicWrite(destFull) rewrites (and re-cases) it; the
398
+ // source unlink below would then delete the just-written file, so skip it.
399
+ const caseOnlySameFile =
400
+ (v4aRenamePathKey(srcFull) === v4aRenamePathKey(destFull) && srcFull !== destFull)
401
+ || renameTargetsSamePhysicalFile(srcFull, destFull);
366
402
  const displaySrc = normalizeOutputPath(section.path);
367
403
  const displayDest = normalizeOutputPath(section.movePath);
368
404
  let sourceLines;
@@ -398,7 +434,7 @@ async function applyV4ARenameSection(section, basePath, options = {}) {
398
434
  mkdirSync(pathDirname(destFull), { recursive: true });
399
435
  try {
400
436
  await atomicWrite(destFull, newContent, { sessionId: options.readStateScope });
401
- await unlink(srcFull);
437
+ if (!caseOnlySameFile) await unlink(srcFull);
402
438
  } catch (err) {
403
439
  try {
404
440
  if (destBefore === null) {
@@ -407,9 +443,11 @@ async function applyV4ARenameSection(section, basePath, options = {}) {
407
443
  await atomicWrite(destFull, destBefore, { sessionId: options.readStateScope });
408
444
  }
409
445
  } catch {}
410
- try {
411
- await atomicWrite(srcFull, originalContent, { sessionId: options.readStateScope });
412
- } catch {}
446
+ if (!caseOnlySameFile) {
447
+ try {
448
+ await atomicWrite(srcFull, originalContent, { sessionId: options.readStateScope });
449
+ } catch {}
450
+ }
413
451
  throw new Error(`apply_patch: V4A rename failed for ${displaySrc} → ${displayDest} (${err?.message || String(err)})`);
414
452
  }
415
453
  invalidateBuiltinResultCache([srcFull, destFull]);
@@ -698,6 +698,11 @@ export function execShellCommand({
698
698
  // Every subsequent stdout/stderr chunk must hit disk — the call is
699
699
  // about to resolve and nobody will drain the in-memory buffers again.
700
700
  try { taskOutput.forceSpill(); } catch {}
701
+ // The foreground sizeWatchdog was cleared above; the output cap now
702
+ // travels with the adopted job — adoptForegroundShellJob arms a periodic
703
+ // refreshShellJob tick that enforces SHELL_JOB_OUTPUT_DISK_CAP against the
704
+ // same spill files (stdoutPath/stderrPath below), killing + flagging a
705
+ // runaway background producer even with no active task waiter.
701
706
  const stdoutPath = taskOutput.spilled ? taskOutput.stdoutPath : null;
702
707
  const stderrPath = taskOutput.spilled ? taskOutput.stderrPath : null;
703
708
  let job = null;
@@ -92,6 +92,7 @@ class TelegramBackend {
92
92
  _polling = false;
93
93
  _pollAbort = null;
94
94
  _connectPromise = null;
95
+ _pollGen = 0;
95
96
  _offset = 0;
96
97
  _typingIntervals = /* @__PURE__ */ new Map();
97
98
  constructor(config, stateDir) {
@@ -141,12 +142,25 @@ class TelegramBackend {
141
142
  }
142
143
  return a;
143
144
  }
144
- _isAllowedChat(chatId) {
145
+ _isAllowedChat(chatId, userId) {
145
146
  const access = this.loadAccess();
146
147
  if (!access || access.dmPolicy === "disabled") return false;
147
148
  const key = String(chatId);
148
- if (this.mainChannelId && key === String(this.mainChannelId)) return true;
149
- if (key in (access.channels ?? {})) return true;
149
+ const uid = userId != null ? String(userId) : null;
150
+ // Mirror discord.mjs gate(): a configured channel/group's allowFrom list
151
+ // (when non-empty) restricts to those user IDs, even though the chatId
152
+ // itself is allowed. An empty allowFrom stays open (existing behavior).
153
+ const channelPolicy = access.channels?.[key];
154
+ if (this.mainChannelId && key === String(this.mainChannelId)) {
155
+ const allowFrom = channelPolicy?.allowFrom ?? [];
156
+ if (allowFrom.length > 0 && (uid == null || !allowFrom.includes(uid))) return false;
157
+ return true;
158
+ }
159
+ if (channelPolicy) {
160
+ const allowFrom = channelPolicy.allowFrom ?? [];
161
+ if (allowFrom.length > 0 && (uid == null || !allowFrom.includes(uid))) return false;
162
+ return true;
163
+ }
150
164
  if ((access.allowFrom ?? []).includes(key)) return true;
151
165
  return false;
152
166
  }
@@ -200,12 +214,16 @@ class TelegramBackend {
200
214
  }
201
215
  this._polling = true;
202
216
  // Fire-and-forget the poll loop; it self-reschedules until _polling clears.
203
- void this._pollLoop();
217
+ // Bump the generation so a loop started by an earlier connect()/backoff
218
+ // cycle (still asleep in its retry delay) recognizes it's stale and
219
+ // exits instead of running a second loop alongside this one.
220
+ const gen = ++this._pollGen;
221
+ void this._pollLoop(gen);
204
222
  })();
205
223
  return this._connectPromise;
206
224
  }
207
- async _pollLoop() {
208
- while (this._polling) {
225
+ async _pollLoop(gen) {
226
+ while (this._polling && gen === this._pollGen) {
209
227
  const ac = new AbortController();
210
228
  this._pollAbort = ac;
211
229
  // getUpdates long-poll: server holds up to `timeout`s; give the client
@@ -232,13 +250,14 @@ class TelegramBackend {
232
250
  }
233
251
  } catch (err) {
234
252
  clearTimeout(timer);
235
- if (!this._polling) break; // disconnect() aborted us — exit quietly.
253
+ if (!this._polling || gen !== this._pollGen) break; // disconnect()/reconnect superseded us — exit quietly.
236
254
  // 409 = another getUpdates/webhook consumer is active; other errors are
237
255
  // transient. Back off and continue rather than crashing the loop.
238
256
  const status = err?.status;
239
257
  const backoff = status === 409 ? 5_000 : 2_000;
240
258
  process.stderr.write(`mixdog telegram: getUpdates error (${status ?? "net"}); retrying in ${backoff}ms\n`);
241
259
  await new Promise((r) => setTimeout(r, backoff));
260
+ if (!this._polling || gen !== this._pollGen) break;
242
261
  } finally {
243
262
  this._pollAbort = null;
244
263
  }
@@ -247,10 +266,10 @@ class TelegramBackend {
247
266
  _handleUpdate(u) {
248
267
  const msg = u.message;
249
268
  if (!msg) return; // non-message update (we only asked for messages).
269
+ const from = msg.from ?? {};
250
270
  const chatId = msg.chat?.id;
251
271
  if (chatId == null) return;
252
- if (!this._isAllowedChat(chatId)) return;
253
- const from = msg.from ?? {};
272
+ if (!this._isAllowedChat(chatId, from.id)) return;
254
273
  if (from.is_bot) return; // ignore bot echoes / other bots.
255
274
  const text = msg.text ?? msg.caption ?? "";
256
275
  const receivedAtMs = Date.now();
@@ -275,6 +294,7 @@ class TelegramBackend {
275
294
  }
276
295
  async disconnect() {
277
296
  this._polling = false;
297
+ this._pollGen++;
278
298
  try { this._pollAbort?.abort(); } catch {}
279
299
  this._pollAbort = null;
280
300
  for (const interval of this._typingIntervals.values()) clearInterval(interval);
@@ -221,9 +221,13 @@ ${p.item.prompt}`).join("\n\n")}`;
221
221
  };
222
222
  logEvent(`${name}: processing batch of ${claimedPairs.length}`);
223
223
  const injected = this.executeItem(batchItem, null);
224
- for (const { file: claimedPath } of claimedPairs) {
224
+ for (const { file: claimedPath, item: originalItem } of claimedPairs) {
225
225
  if (injected) this.moveInProgressToProcessed(claimedPath, "batched");
226
- else this.requeueClaimed(claimedPath, batchItem);
226
+ // Requeue each claimant with its own original item, not the combined
227
+ // batchItem — otherwise every retry re-inflates that file's prompt
228
+ // with the whole prior batch's text, and re-batching next tick
229
+ // compounds it further.
230
+ else this.requeueClaimed(claimedPath, originalItem);
227
231
  }
228
232
  }
229
233
  }