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.
- package/package.json +1 -1
- package/scripts/bench/cache-probe-tasks.json +1 -1
- package/scripts/bench/lead-review-tasks-r3.json +1 -1
- package/scripts/bench/r4-mixed-tasks.json +1 -1
- package/scripts/bench/review-tasks.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench.mjs +5 -4
- package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
- package/scripts/output-style-smoke.mjs +3 -3
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/termio-input-smoke.mjs +199 -0
- package/scripts/tool-efficiency-diag.mjs +88 -0
- package/scripts/tool-smoke.mjs +40 -24
- package/scripts/tui-frame-harness-shim.mjs +32 -0
- package/scripts/tui-frame-harness.mjs +306 -0
- package/src/agents/heavy-worker/AGENT.md +6 -7
- package/src/agents/worker/AGENT.md +6 -7
- package/src/lib/keychain-cjs.cjs +28 -11
- package/src/lib/rules-builder.cjs +6 -10
- package/src/mixdog-session-runtime.mjs +90 -20
- package/src/output-styles/simple.md +22 -24
- package/src/rules/agent/00-core.md +12 -11
- package/src/rules/agent/30-explorer.md +22 -21
- package/src/rules/lead/01-general.md +8 -8
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-brief.md +11 -12
- package/src/rules/shared/01-tool.md +25 -14
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
- package/src/runtime/channels/backends/telegram.mjs +29 -9
- package/src/runtime/channels/lib/event-queue.mjs +6 -2
- package/src/runtime/channels/lib/memory-client.mjs +66 -1
- package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
- package/src/runtime/memory/index.mjs +95 -8
- package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
- package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
- package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
- package/src/runtime/memory/lib/memory-embed.mjs +48 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
- package/src/runtime/memory/lib/memory.mjs +88 -1
- package/src/runtime/memory/lib/session-ingest.mjs +34 -3
- package/src/runtime/memory/lib/trace-store.mjs +52 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
- package/src/runtime/shared/child-guardian.mjs +4 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/singleton-owner.mjs +26 -0
- package/src/runtime/shared/tool-execution-contract.mjs +25 -0
- package/src/runtime/shared/transcript-writer.mjs +3 -1
- package/src/session-runtime/config-helpers.mjs +7 -2
- package/src/session-runtime/cwd-plugins.mjs +6 -1
- package/src/session-runtime/effort.mjs +6 -1
- package/src/session-runtime/plugin-mcp.mjs +116 -12
- package/src/session-runtime/settings-api.mjs +7 -1
- package/src/standalone/channel-worker.mjs +25 -3
- package/src/standalone/explore-tool.mjs +5 -5
- package/src/standalone/hook-bus/config.mjs +38 -2
- package/src/standalone/hook-bus/handlers.mjs +103 -11
- package/src/standalone/hook-bus.mjs +20 -6
- package/src/standalone/memory-runtime-proxy.mjs +40 -5
- package/src/tui/App.jsx +214 -30
- package/src/tui/app/core-memory-picker.mjs +6 -6
- package/src/tui/app/model-options.mjs +10 -4
- package/src/tui/app/settings-picker.mjs +5 -30
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/text-layout.mjs +57 -0
- package/src/tui/app/transcript-window.mjs +53 -2
- package/src/tui/app/use-mouse-input.mjs +60 -47
- package/src/tui/app/use-transcript-window.mjs +38 -10
- package/src/tui/components/PromptInput.jsx +18 -1
- package/src/tui/components/TextEntryPanel.jsx +97 -33
- package/src/tui/dist/index.mjs +487 -211
- package/src/tui/engine/tool-card-results.mjs +22 -5
- package/src/tui/engine.mjs +35 -5
- package/src/tui/index.jsx +4 -1
- package/src/workflows/default/WORKFLOW.md +27 -31
- package/vendor/ink/build/components/App.js +62 -17
- package/vendor/ink/build/ink.js +78 -3
- package/vendor/ink/build/input-parser.d.ts +9 -4
- package/vendor/ink/build/input-parser.js +45 -176
- package/vendor/ink/build/log-update.js +47 -2
- package/vendor/ink/build/termio-keypress.js +240 -0
- 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
|
-
|
|
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
|
-
|
|
731
|
-
if (hint)
|
|
732
|
-
return msg +
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
}
|