zames_pro 2.78.1 → 2.78.3
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/CHANGELOG.md +42 -0
- package/dist/answer-parse.js +58 -0
- package/dist/attach-refs.js +24 -1
- package/dist/backlog.js +4 -1
- package/dist/checkpoint.js +7 -3
- package/dist/diagnostics.js +9 -3
- package/dist/fsutil.js +13 -2
- package/dist/gitTools.js +12 -5
- package/dist/goal.js +14 -2
- package/dist/hooks.js +21 -3
- package/dist/input/suggest.js +14 -1
- package/dist/mcp.js +7 -1
- package/dist/net-capture.js +33 -6
- package/dist/permissions.js +19 -1
- package/dist/provider-handoff.js +25 -1
- package/dist/qwen-net-capture.js +8 -0
- package/dist/scheduler.js +24 -7
- package/dist/sessions.js +10 -4
- package/dist/shell.js +40 -3
- package/dist/statusline.js +9 -0
- package/dist/tool-summary.js +10 -1
- package/dist/tools.js +23 -4
- package/dist/transcript.js +15 -5
- package/dist/undo.js +43 -18
- package/dist/web.js +80 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.78.3] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **answer-parse:** a report narrating completed steps is not unfinished work
|
|
15
|
+
- **net-capture:** a short complete answer is not a finished-without-answer turn
|
|
16
|
+
- **git:** keep the empty-output sentinel out of the git context
|
|
17
|
+
- **tools:** make Read offset/limit 1-based like every line-numbered tool
|
|
18
|
+
- **shell:** scan a glued cd target (cd../.. escaped the sandbox)
|
|
19
|
+
- **web:** drop an unclosed script/style body in htmlToText
|
|
20
|
+
- **diagnostics:** recognize a coloured/timestamped clean banner
|
|
21
|
+
- **permissions:** fail closed on a rule with an invalid regex
|
|
22
|
+
- **transcript:** sanitize the session name in the file name
|
|
23
|
+
- **backlog:** strip a CR from a note title
|
|
24
|
+
- **shell:** escalate to SIGKILL when a killed job traps SIGTERM
|
|
25
|
+
- **input:** Tab on an empty buffer inserted a guessed command
|
|
26
|
+
|
|
27
|
+
## [2.78.2] - 2026-10-18
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **goal:** clampGoalCondition does not cut a surrogate pair
|
|
32
|
+
- **web:** judge the expanded IPv6 form in the SSRF guard
|
|
33
|
+
- **permissions:** match a command rule against an avv array
|
|
34
|
+
- **scheduler:** OR a restricted day-of-month and day-of-week
|
|
35
|
+
- **qwen:** guest-limit classifier ignores a normal answer
|
|
36
|
+
- **net-capture:** read a top-level accumulated_token_usage pair
|
|
37
|
+
- **tools:** Read does not clip a surrogate pair in half
|
|
38
|
+
- **tool-summary:** do not cut a surrogate pair in the log line
|
|
39
|
+
- **shell:** do not start a background trim on a lone surrogate
|
|
40
|
+
- **sandbox:** match eval at a command position, not as a substring
|
|
41
|
+
- **goal:** parse the spaced and hyphenated NOT MET verdict
|
|
42
|
+
- **hooks:** cap the combined PostToolUse and lifecycle output
|
|
43
|
+
- **fsutil:** write atomic files through a symlink, not over it
|
|
44
|
+
- **mcp:** sanitize the server half of a qualified tool name
|
|
45
|
+
- **statusline:** settle on our own stdout end, not on the pipe closing
|
|
46
|
+
- **provider:** match the handoff workdir by real path, not by string
|
|
47
|
+
- **checkpoint:** retention deletes same-millisecond sibling archives
|
|
48
|
+
- **undo:** a missing backup file no longer wedges the undo history
|
|
49
|
+
- **sessions:** a stale internal id no longer hides the saved session
|
|
50
|
+
- **attach:** keep @file references inside the sandbox root
|
|
51
|
+
|
|
10
52
|
## [2.78.1] - 2026-10-18
|
|
11
53
|
|
|
12
54
|
### Fixed
|
package/dist/answer-parse.js
CHANGED
|
@@ -111,6 +111,64 @@ export function looksLikeUnfinishedWork(text) {
|
|
|
111
111
|
if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
|
|
112
112
|
return false;
|
|
113
113
|
}
|
|
114
|
+
// A report that NARRATES a completed step ("Tests pass. Now I will run
|
|
115
|
+
// the build.", "I have updated the README.", "Fixed the bug.") recounts
|
|
116
|
+
// work that is ALREADY done. The future-tense "now …" that follows must not
|
|
117
|
+
// turn such a report back into unfinished work: the loop re-asked the model
|
|
118
|
+
// up to 3 times on a complete answer. A bare promise ("Let me run the
|
|
119
|
+
// tests") has NO completed-step clause and is still caught below.
|
|
120
|
+
// The prefix and the verb are matched SEPARATELY (not as one combined
|
|
121
|
+
// regex): with a Cyrillic alternation glued to a `\b`-prefixed group, V8
|
|
122
|
+
// silently fails to find the match. A short English/Russian verb list is
|
|
123
|
+
// checked with plain `includes` on the lowercased text.
|
|
124
|
+
const lower = t.toLowerCase();
|
|
125
|
+
const completedWords = [
|
|
126
|
+
'passed',
|
|
127
|
+
'succeeded',
|
|
128
|
+
'failed',
|
|
129
|
+
'updated',
|
|
130
|
+
'fixed',
|
|
131
|
+
'added',
|
|
132
|
+
'removed',
|
|
133
|
+
'applied',
|
|
134
|
+
'committed',
|
|
135
|
+
'pushed',
|
|
136
|
+
'installed',
|
|
137
|
+
'verified',
|
|
138
|
+
'changed',
|
|
139
|
+
'modified',
|
|
140
|
+
'implemented',
|
|
141
|
+
'reviewed',
|
|
142
|
+
'created',
|
|
143
|
+
'built',
|
|
144
|
+
'pass.',
|
|
145
|
+
'pass,',
|
|
146
|
+
'pass ',
|
|
147
|
+
'have ',
|
|
148
|
+
'has ',
|
|
149
|
+
'проходят',
|
|
150
|
+
'прошёл',
|
|
151
|
+
'прошел',
|
|
152
|
+
'успешн',
|
|
153
|
+
'готов',
|
|
154
|
+
];
|
|
155
|
+
const completedRu = [
|
|
156
|
+
'исправил',
|
|
157
|
+
'добавил',
|
|
158
|
+
'обновил',
|
|
159
|
+
'создал',
|
|
160
|
+
'удалил',
|
|
161
|
+
'применил',
|
|
162
|
+
'запустил',
|
|
163
|
+
'проверил',
|
|
164
|
+
'изменил',
|
|
165
|
+
'сделал',
|
|
166
|
+
'выполнил',
|
|
167
|
+
];
|
|
168
|
+
if (completedWords.some((w) => lower.includes(w)) ||
|
|
169
|
+
completedRu.some((w) => lower.includes(w))) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
114
172
|
// `update`/`summarize`/`explain` are REPORT verbs (`I will update you on the
|
|
115
173
|
// results: …`) — they do not promise a tool call. A colon anywhere in the
|
|
116
174
|
// gap also means the sentence is explaining a fact, not about to act.
|
package/dist/attach-refs.js
CHANGED
|
@@ -9,6 +9,15 @@ const NL = String.fromCharCode(10);
|
|
|
9
9
|
// Resolve a pasted string into a path to an EXISTING file. Handles quoted
|
|
10
10
|
// paths (drag&drop from some file managers adds quotes) and paths relative to
|
|
11
11
|
// the working directory. Returns null when nothing matches.
|
|
12
|
+
//
|
|
13
|
+
// Every candidate is checked against the sandbox root (the REAL path, so
|
|
14
|
+
// a symlink escape is refused too). The `..`/cwd candidates exist to help an
|
|
15
|
+
// operator paste a path that LOOKS relative to the project, not to grant
|
|
16
|
+
// access above it: without this guard a reference with a `..` inlined a file
|
|
17
|
+
// outside the sandbox, while every other file tool rejects that path via
|
|
18
|
+
// safePath(). The search continues past an escaping candidate (a regular
|
|
19
|
+
// file inside the project must still resolve), but no candidate outside is
|
|
20
|
+
// ever returned.
|
|
12
21
|
export async function resolveAttachPath(workdir, raw) {
|
|
13
22
|
let s = String(raw || '').trim();
|
|
14
23
|
if (!s)
|
|
@@ -34,11 +43,25 @@ export async function resolveAttachPath(workdir, raw) {
|
|
|
34
43
|
];
|
|
35
44
|
for (const c of candidates) {
|
|
36
45
|
const st = await fs.stat(c).catch(() => null);
|
|
37
|
-
if (st
|
|
46
|
+
if (!st || !st.isFile())
|
|
47
|
+
continue;
|
|
48
|
+
if (await isInside(workdir, c))
|
|
38
49
|
return c;
|
|
39
50
|
}
|
|
40
51
|
return null;
|
|
41
52
|
}
|
|
53
|
+
// True when the REAL path of `target` stays under the REAL workdir root.
|
|
54
|
+
// realpath (not the lexical path) is required here for the same reason as
|
|
55
|
+
// safePath() in src/sandbox.ts: a symlink inside the project can point
|
|
56
|
+
// outside it.
|
|
57
|
+
async function isInside(workdir, target) {
|
|
58
|
+
const root = await fs.realpath(path.resolve(workdir)).catch(() => null);
|
|
59
|
+
const real = await fs.realpath(target).catch(() => null);
|
|
60
|
+
if (!root || !real)
|
|
61
|
+
return false;
|
|
62
|
+
const rel = path.relative(root, real);
|
|
63
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
64
|
+
}
|
|
42
65
|
// Per-file and total caps for `@path` inlining (B5). A reference should SAVE
|
|
43
66
|
// round-trips, not flood the context: a huge file is truncated with a note
|
|
44
67
|
// telling the agent to read the rest itself.
|
package/dist/backlog.js
CHANGED
|
@@ -30,7 +30,10 @@ export function parseBacklogNote(note) {
|
|
|
30
30
|
break;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
// A note typed on Windows (or pasted from one) carries CRLF line endings;
|
|
34
|
+
// the title would keep the trailing `\r` and the heading would get a stray
|
|
35
|
+
// carriage return.
|
|
36
|
+
const bodyLines = body.split(NL).map((l) => l.replace(/\r$/, ''));
|
|
34
37
|
const title = bodyLines[0];
|
|
35
38
|
const rest = bodyLines.slice(1).join(NL).trim();
|
|
36
39
|
return { priority, title, note: rest || undefined };
|
package/dist/checkpoint.js
CHANGED
|
@@ -176,10 +176,14 @@ export class CheckpointStore {
|
|
|
176
176
|
const history = await this._readIndex();
|
|
177
177
|
history.push(record);
|
|
178
178
|
const kept = rotateRecords(history, this.maxBackups);
|
|
179
|
-
// Drop the archives that fell out of the retention window.
|
|
180
|
-
|
|
179
|
+
// Drop the archives that fell out of the retention window. Keyed by the
|
|
180
|
+
// RECORD, not by its stamp: the stamp is ms-precision, so two snapshots of
|
|
181
|
+
// one workdir taken in the same millisecond share it — the pruned record
|
|
182
|
+
// then looked "kept" thanks to its sibling and its archive stayed on disk
|
|
183
|
+
// as an orphan forever.
|
|
184
|
+
const keptSet = new Set(kept);
|
|
181
185
|
for (const r of history) {
|
|
182
|
-
if (!
|
|
186
|
+
if (!keptSet.has(r))
|
|
183
187
|
await fs.rm(r.file, { force: true }).catch(() => { });
|
|
184
188
|
}
|
|
185
189
|
writeJsonAtomic(this.indexFile, kept);
|
package/dist/diagnostics.js
CHANGED
|
@@ -28,11 +28,17 @@ export function summarizeDiagnostics(raw, maxChars) {
|
|
|
28
28
|
// old 40-char cutoff — so every edit in watch mode appended a pointless
|
|
29
29
|
// diagnostics block to the tool result. Treat a ZERO-count banner as clean
|
|
30
30
|
// however long it is; a non-zero count still falls through to the lines.
|
|
31
|
-
|
|
31
|
+
// Strip ANSI colour codes and a leading `[12:00:00]`-style timestamp before
|
|
32
|
+
// the clean checks: a watch-mode banner is usually coloured and/or
|
|
33
|
+
// prefixed, and the anchored regexes missed it — every edit then
|
|
34
|
+
// appended a pointless diagnostics block.
|
|
35
|
+
const plain = text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
36
|
+
const cleanCheck = plain.replace(/^\[[^\]]*\]\s*/, '');
|
|
37
|
+
if (/^(?:found\s+)?0\s+errors?\b/im.test(cleanCheck))
|
|
32
38
|
return null;
|
|
33
|
-
if (/^no errors?\.?$/im.test(
|
|
39
|
+
if (/^no errors?(?: found)?\.?$/im.test(cleanCheck))
|
|
34
40
|
return null;
|
|
35
|
-
if (/^0 problems?\b/im.test(
|
|
41
|
+
if (/^0 problems?\b/im.test(cleanCheck))
|
|
36
42
|
return null;
|
|
37
43
|
// Keep only error-looking lines when we can recognise them, so a long build
|
|
38
44
|
// log collapses to the actual problems. Fall back to the whole output.
|
package/dist/fsutil.js
CHANGED
|
@@ -16,7 +16,18 @@ import path from 'path';
|
|
|
16
16
|
// multi-user machine. We also chmod the target after rename because a
|
|
17
17
|
// pre-existing file keeps its old mode (rename does not change the inode).
|
|
18
18
|
export function writeFileAtomic(file, data) {
|
|
19
|
-
|
|
19
|
+
// Follow a SYMLINK first: a dotfiles setup links ~/.zames/config.json into a
|
|
20
|
+
// git repo, and a project may link .zamesrc.json. Renaming a temp file over
|
|
21
|
+
// the link replaces the LINK with a regular file — the real target keeps the
|
|
22
|
+
// stale content and the next launch reads what the operator never edited.
|
|
23
|
+
let target = file;
|
|
24
|
+
try {
|
|
25
|
+
target = fs.realpathSync(file);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Missing (first write) or broken link: write to the path itself.
|
|
29
|
+
}
|
|
30
|
+
const tmp = target + '.tmp-' + process.pid;
|
|
20
31
|
// fsync the file BEFORE rename: without it a power loss can leave the
|
|
21
32
|
// renamed name pointing at an empty/partial inode (rename itself is atomic,
|
|
22
33
|
// but the data blocks may not have reached the disk yet).
|
|
@@ -29,7 +40,7 @@ export function writeFileAtomic(file, data) {
|
|
|
29
40
|
finally {
|
|
30
41
|
fs.closeSync(fd);
|
|
31
42
|
}
|
|
32
|
-
fs.renameSync(tmp,
|
|
43
|
+
fs.renameSync(tmp, target);
|
|
33
44
|
}
|
|
34
45
|
catch (e) {
|
|
35
46
|
// A failed write/fsync/rename (full disk, EIO, a directory in the way)
|
package/dist/gitTools.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { execFile } from 'child_process';
|
|
2
2
|
const BS = String.fromCharCode(92);
|
|
3
|
+
// What runGitArgs answers for an EMPTY successful run. Callers that parse the
|
|
4
|
+
// output must not mistake it for real content (a clean tree is not "one
|
|
5
|
+
// modified file whose name is this string").
|
|
6
|
+
export const NO_OUTPUT_SENTINEL = '(command produced no output)';
|
|
3
7
|
// Run git with an argument ARRAY (no shell). ALL git invocations go through
|
|
4
8
|
// this: an argument from the model can never be seen by a shell, so $(...),
|
|
5
9
|
// backticks and a leading `-` (option injection) cannot turn a read-only call
|
|
@@ -22,7 +26,7 @@ export function runGitArgs(args, cwd, timeout = 15_000) {
|
|
|
22
26
|
const errStr = (stderr || '').toString();
|
|
23
27
|
if (!err) {
|
|
24
28
|
const combined = (out + errStr).trim();
|
|
25
|
-
resolve(combined ||
|
|
29
|
+
resolve(combined || NO_OUTPUT_SENTINEL);
|
|
26
30
|
return;
|
|
27
31
|
}
|
|
28
32
|
const parts = [];
|
|
@@ -49,7 +53,11 @@ export async function getGitContext(workdir) {
|
|
|
49
53
|
const remoteR = await runGitArgs(['remote'], workdir, 5000);
|
|
50
54
|
const aheadR = await runGitArgs(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], workdir, 5000);
|
|
51
55
|
const branch = branchR.trim() || '(detached HEAD)';
|
|
52
|
-
|
|
56
|
+
// The shared runner answers an EMPTY successful run with a sentinel. Storing
|
|
57
|
+
// it verbatim told the model the tree had a modified "file" that is really
|
|
58
|
+
// an internal string — every task started with a false dirty-tree premise.
|
|
59
|
+
const statusText = statusR.trim() === NO_OUTPUT_SENTINEL ? '' : statusR;
|
|
60
|
+
const statusLines = statusText
|
|
53
61
|
.split(String.fromCharCode(10))
|
|
54
62
|
.map((l) => l.trimEnd())
|
|
55
63
|
.filter(Boolean);
|
|
@@ -84,7 +92,7 @@ export function formatGitContext(ctx) {
|
|
|
84
92
|
else {
|
|
85
93
|
s += '\n- Remote: (none)';
|
|
86
94
|
}
|
|
87
|
-
if (ctx.changedFiles > 0) {
|
|
95
|
+
if (ctx.changedFiles > 0 && ctx.statusPreview.trim() !== NO_OUTPUT_SENTINEL) {
|
|
88
96
|
s += `\n- Uncommitted changes: ${ctx.changedFiles} file(s)
|
|
89
97
|
`;
|
|
90
98
|
// Choose a fence longer than any backtick run in the preview: a filename
|
|
@@ -234,8 +242,7 @@ export function createGitTools(workdir) {
|
|
|
234
242
|
// We stage everything only if the index is empty (as the description promises).
|
|
235
243
|
const staged = await runGitArgs(['diff', '--cached', '--name-only'], workdir, 10_000);
|
|
236
244
|
const stagedTrimmed = staged.trim();
|
|
237
|
-
const hasStaged = stagedTrimmed !== '' &&
|
|
238
|
-
stagedTrimmed !== '(command produced no output)';
|
|
245
|
+
const hasStaged = stagedTrimmed !== '' && stagedTrimmed !== NO_OUTPUT_SENTINEL;
|
|
239
246
|
const addResult = hasStaged
|
|
240
247
|
? '(index is not empty — add -A skipped)'
|
|
241
248
|
: await runGitArgs(['add', '-A'], workdir, 20_000);
|
package/dist/goal.js
CHANGED
|
@@ -13,7 +13,14 @@ export function clampGoalCondition(condition) {
|
|
|
13
13
|
const g = String(condition ?? '').trim();
|
|
14
14
|
if (g.length <= GOAL_CONDITION_CAP)
|
|
15
15
|
return g;
|
|
16
|
-
|
|
16
|
+
// Never cut a surrogate PAIR in half: the condition is injected into the
|
|
17
|
+
// evaluator prompt, and a lone high surrogate is a broken code point (same
|
|
18
|
+
// rule as truncateToolResult / capReport / clipLine / oneLine).
|
|
19
|
+
let cut = GOAL_CONDITION_CAP;
|
|
20
|
+
const last = g.charCodeAt(cut - 1);
|
|
21
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
22
|
+
cut--;
|
|
23
|
+
return g.slice(0, cut);
|
|
17
24
|
}
|
|
18
25
|
// The evaluator prompt is AGENT-facing (English), like the system prompt and
|
|
19
26
|
// the compact summary prompt. It runs in the SAME chat as the work so the
|
|
@@ -74,7 +81,12 @@ export function parseGoalVerdict(text) {
|
|
|
74
81
|
// spaces (`**VERDICT:** MET`) — a markdown-model answer did exactly that and
|
|
75
82
|
// the strict `VERDICT:` matcher returned null, so the goal never parsed and
|
|
76
83
|
// the loop auto-continued until the turn budget.
|
|
77
|
-
|
|
84
|
+
// The value itself may be written the human way (`NOT MET`, `NOT-MET`): a
|
|
85
|
+
// strict `NOT_MET` matcher returned null for those, and null means "continue"
|
|
86
|
+
// — the goal loop then auto-continued until the turn budget although the
|
|
87
|
+
// judge had already said NOT_MET. `MET` is tried LAST so it cannot match the
|
|
88
|
+
// tail of `NOT MET`.
|
|
89
|
+
const m = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(IMPOSSIBLE|NOT[_ -]?MET|MET)/i.exec(t);
|
|
78
90
|
if (!m)
|
|
79
91
|
return null;
|
|
80
92
|
const raw = m[1].toUpperCase();
|
package/dist/hooks.js
CHANGED
|
@@ -15,6 +15,9 @@ const HOOK_MAX_BUFFER = 1024 * 1024;
|
|
|
15
15
|
// A PreToolUse denial becomes the tool result the model reads, so it is capped
|
|
16
16
|
// like any other tool output.
|
|
17
17
|
const HOOK_DENIAL_CAP = 4000;
|
|
18
|
+
// Combined stdout of a PostToolUse / lifecycle hook. It lands in a tool result
|
|
19
|
+
// or in the chat, so it is a notice, not a log dump.
|
|
20
|
+
const HOOK_OUTPUT_CAP = 8000;
|
|
18
21
|
export function hooksConfigPath(workdir) {
|
|
19
22
|
return path.join(workdir, '.zames', 'hooks.json');
|
|
20
23
|
}
|
|
@@ -181,13 +184,28 @@ export async function runLifecycleHooks(hooks, event, workdir, extra = {}) {
|
|
|
181
184
|
async function runEventHooks(hooks, event, workdir, tool, args, result) {
|
|
182
185
|
const list = hooks?.[event];
|
|
183
186
|
const parts = [];
|
|
187
|
+
let total = 0;
|
|
184
188
|
for (const entry of list ?? []) {
|
|
185
189
|
if (!matchesHook(entry, tool))
|
|
186
190
|
continue;
|
|
187
191
|
const r = await runHook(entry, event, tool, args, workdir, result);
|
|
188
192
|
const out = r.stdout.trim();
|
|
189
|
-
if (out)
|
|
190
|
-
|
|
193
|
+
if (!out)
|
|
194
|
+
continue;
|
|
195
|
+
// The combined output goes into a tool result (PostToolUse) or straight
|
|
196
|
+
// into the chat (SessionStart and the other lifecycle events), so it must
|
|
197
|
+
// stay a notice: a hook running `npm test` or dumping a log used to append
|
|
198
|
+
// hundreds of KB and flood the context.
|
|
199
|
+
if (total >= HOOK_OUTPUT_CAP)
|
|
200
|
+
break;
|
|
201
|
+
const piece = out.slice(0, HOOK_OUTPUT_CAP - total);
|
|
202
|
+
total += piece.length;
|
|
203
|
+
parts.push(piece);
|
|
191
204
|
}
|
|
192
|
-
|
|
205
|
+
const joined = parts.join(String.fromCharCode(10));
|
|
206
|
+
if (joined.length <= HOOK_OUTPUT_CAP)
|
|
207
|
+
return joined;
|
|
208
|
+
return (joined.slice(0, HOOK_OUTPUT_CAP) +
|
|
209
|
+
String.fromCharCode(10) +
|
|
210
|
+
'… [hook output truncated]');
|
|
193
211
|
}
|
package/dist/input/suggest.js
CHANGED
|
@@ -100,10 +100,23 @@ export function completeSlashCommand(buf, sugg, selected) {
|
|
|
100
100
|
if (sugg.length === 1)
|
|
101
101
|
return sugg[0].name + ' ';
|
|
102
102
|
let prefix = sugg[0].name;
|
|
103
|
+
const typed = buf.toLowerCase();
|
|
103
104
|
for (const c of sugg) {
|
|
104
|
-
while (
|
|
105
|
+
while (prefix.length > typed.length &&
|
|
106
|
+
!c.name.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
105
107
|
prefix = prefix.slice(0, -1);
|
|
106
108
|
}
|
|
107
109
|
}
|
|
110
|
+
// Stop shrinking at the TYPED text: with an empty buffer the list matches
|
|
111
|
+
// everything, and the shared prefix used to shrink down to a bare "/" (or a
|
|
112
|
+
// common first letter like "/h") — Tab then inserted something the operator
|
|
113
|
+
// never typed. A completion must EXTEND what is already there.
|
|
114
|
+
// With an EMPTY buffer everything matches and the shared prefix collapses
|
|
115
|
+
// to a bare "/" (or a common first letter like "/h"): Tab must not guess a
|
|
116
|
+
// command the operator has not started typing.
|
|
117
|
+
if (typed.length < 2)
|
|
118
|
+
return null;
|
|
119
|
+
if (!prefix.toLowerCase().startsWith(typed))
|
|
120
|
+
return null;
|
|
108
121
|
return prefix.length > buf.length ? prefix : null;
|
|
109
122
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -275,7 +275,13 @@ function safeJson(v) {
|
|
|
275
275
|
// servers cannot collide. The prefix uses __ (double underscore) because
|
|
276
276
|
// the model must be able to reproduce it exactly.
|
|
277
277
|
export function qualifyToolName(server, tool) {
|
|
278
|
-
|
|
278
|
+
// BOTH halves must be sanitized: a server name with a space ('my server')
|
|
279
|
+
// produced 'my server__tool', which the model cannot reproduce in a JSON
|
|
280
|
+
// tool call. The same rule on both sides also makes two spellings of one
|
|
281
|
+
// server collide on ONE qualified name, so dedupeToolNames can rename the
|
|
282
|
+
// duplicate instead of leaving both tools unreachable.
|
|
283
|
+
const clean = (s) => String(s || '').replace(/[^A-Za-z0-9_]/g, '_');
|
|
284
|
+
return clean(server) + '__' + clean(tool);
|
|
279
285
|
}
|
|
280
286
|
// Make every tool name unique IN PLACE. agent-loop resolves a call by name and
|
|
281
287
|
// takes the first match, so a duplicate silently shadows the later tool. The
|
package/dist/net-capture.js
CHANGED
|
@@ -169,9 +169,12 @@ export function extractAnswer(body) {
|
|
|
169
169
|
// isFinishedWithoutAnswer === false): the finish loop kept polling for a
|
|
170
170
|
// complete answer and `ask()` gave up only at the askDeadlineMs watchdog — the
|
|
171
171
|
// operator watched a spinner for ~4 minutes with no tool call.
|
|
172
|
-
// A real answer
|
|
173
|
-
//
|
|
174
|
-
|
|
172
|
+
// A real answer is anything non-empty. The one case that must NOT count is a
|
|
173
|
+
// truncated RESPONSE head (an empty string or a bare `{`), which the measure
|
|
174
|
+
// below excludes explicitly.
|
|
175
|
+
const MIN_REAL_ANSWER_CHARS = 1;
|
|
176
|
+
/** A bare truncated head (whitespace or braces only) is not a real answer. */
|
|
177
|
+
const TRUNCATED_HEAD_RE = /^[\s{[]*$/;
|
|
175
178
|
// How many chars of RESPONSE text the body carries, or -1 when there is NO
|
|
176
179
|
// RESPONSE fragment at all. The fragment is SSE/JSON-encoded, so the content
|
|
177
180
|
// may contain `\"` escapes AND raw `"` inside the JSON (the capture is the RAW
|
|
@@ -184,7 +187,25 @@ function responseContentLength(body) {
|
|
|
184
187
|
const b = String(body || '');
|
|
185
188
|
if (!/"type":"RESPONSE"/.test(b))
|
|
186
189
|
return -1;
|
|
187
|
-
|
|
190
|
+
// `extractFromSse` reads only `data:` lines, so a plain-JSON body (or one
|
|
191
|
+
// captured without the SSE framing) measured as 0 even when it carried a
|
|
192
|
+
// real SHORT answer. Count the content strings from the raw body and decode
|
|
193
|
+
// them, so an escaped quote does not inflate the count.
|
|
194
|
+
let len = 0;
|
|
195
|
+
const re = /"type":"RESPONSE"[^}]*?"content":"((?:[^"\\]|\\.)*)"/g;
|
|
196
|
+
let m;
|
|
197
|
+
while ((m = re.exec(b)) !== null) {
|
|
198
|
+
let text = m[1];
|
|
199
|
+
try {
|
|
200
|
+
text = JSON.parse('"' + m[1] + '"');
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
// A bare truncated head (empty, or a lone `{`/`[`) is not an answer; any
|
|
204
|
+
// other non-empty text — however short — is.
|
|
205
|
+
if (!TRUNCATED_HEAD_RE.test(text))
|
|
206
|
+
len += text.length;
|
|
207
|
+
}
|
|
208
|
+
return len;
|
|
188
209
|
}
|
|
189
210
|
// True when the SSE body shows a turn that ENDED (`quasi_status: FINISHED`)
|
|
190
211
|
// but produced NO real answer text — the reasoning (THINK fragment) was
|
|
@@ -209,7 +230,7 @@ export function isFinishedWithoutAnswer(body) {
|
|
|
209
230
|
return false;
|
|
210
231
|
// A RESPONSE fragment with real content means the model DID answer.
|
|
211
232
|
const len = responseContentLength(b);
|
|
212
|
-
if (len >=
|
|
233
|
+
if (len >= MIN_REAL_ANSWER_CHARS)
|
|
213
234
|
return false;
|
|
214
235
|
// No RESPONSE fragment at all (len === -1) or only a truncated head:
|
|
215
236
|
// treat the absence of a real answer as "no answer produced".
|
|
@@ -234,7 +255,7 @@ export function isFinishedWithAnswer(body) {
|
|
|
234
255
|
// A truncated RESPONSE head (a lone `{`) is NOT a complete answer: the
|
|
235
256
|
// finish loop would return it as the model's text and the tool-call parser
|
|
236
257
|
// would fail on it. Require real content (see MIN_FINISHED_ANSWER_CHARS).
|
|
237
|
-
return responseContentLength(b) >=
|
|
258
|
+
return responseContentLength(b) >= MIN_REAL_ANSWER_CHARS;
|
|
238
259
|
}
|
|
239
260
|
// The CONTEXT size (in tokens) DeepSeek reports for the current answer.
|
|
240
261
|
//
|
|
@@ -270,6 +291,12 @@ export function extractTokenUsage(body) {
|
|
|
270
291
|
: undefined;
|
|
271
292
|
if (resp)
|
|
272
293
|
consider(resp.accumulated_token_usage);
|
|
294
|
+
// A BATCH update can also arrive with the pair at the TOP level:
|
|
295
|
+
// {"p":"accumulated_token_usage","v":N} (the same pair DeepSeek nests
|
|
296
|
+
// inside v: [...]). Reading only the nested form made the context counter
|
|
297
|
+
// silently stale for that stream shape.
|
|
298
|
+
if (o.p === 'accumulated_token_usage')
|
|
299
|
+
consider(o.v);
|
|
273
300
|
// A BATCH update: v is an array of {"p":"accumulated_token_usage","v":N}.
|
|
274
301
|
if (Array.isArray(o.v)) {
|
|
275
302
|
for (const item of o.v) {
|
package/dist/permissions.js
CHANGED
|
@@ -110,9 +110,27 @@ export function decidePermission(policy, tool, args) {
|
|
|
110
110
|
if (!policy)
|
|
111
111
|
return { action: 'allow', reason: 'no policy' };
|
|
112
112
|
const a = (args ?? {});
|
|
113
|
-
|
|
113
|
+
// A command may be a STRING (`command`, `cmd`, ...) or an ARRAY (`argv`,
|
|
114
|
+
// the natural shape for execFile-style MCP tools). Accepting only strings
|
|
115
|
+
// let a `command` deny be bypassed by passing the words as an array.
|
|
116
|
+
const commands = [];
|
|
117
|
+
for (const k of COMMAND_KEYS) {
|
|
118
|
+
const v = a[k];
|
|
119
|
+
if (typeof v === 'string')
|
|
120
|
+
commands.push(v);
|
|
121
|
+
else if (Array.isArray(v)) {
|
|
122
|
+
commands.push(v.filter((x) => typeof x === 'string').join(' '));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
114
125
|
const paths = collectPathCandidates(tool, args);
|
|
115
126
|
for (const r of policy.rules) {
|
|
127
|
+
// An UNPARSABLE regex in any field of the rule makes the rule fail
|
|
128
|
+
// CLOSED: skipping it silently (the old behavior) turned a deny rule with
|
|
129
|
+
// a typo (`^(Bash$`) into "allow everything it was meant to block".
|
|
130
|
+
const bad = [r.tool, r.command, r.path].find((src) => src !== undefined && safeRegex(src) === null);
|
|
131
|
+
if (bad !== undefined) {
|
|
132
|
+
return { action: 'deny', reason: 'rule has an invalid regex: ' + bad };
|
|
133
|
+
}
|
|
116
134
|
if (r.tool) {
|
|
117
135
|
const re = safeRegex(r.tool);
|
|
118
136
|
if (!re || !re.test(tool))
|
package/dist/provider-handoff.js
CHANGED
|
@@ -52,7 +52,7 @@ export function readProviderHandoff(provider, workdir) {
|
|
|
52
52
|
}
|
|
53
53
|
if (raw.provider !== provider)
|
|
54
54
|
return null;
|
|
55
|
-
if ((raw.workdir
|
|
55
|
+
if (!sameDir(raw.workdir, workdir))
|
|
56
56
|
return null;
|
|
57
57
|
return raw;
|
|
58
58
|
}
|
|
@@ -60,6 +60,30 @@ export function readProviderHandoff(provider, workdir) {
|
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
// Two paths that denote the SAME directory must match: the handoff is consumed
|
|
64
|
+
// by the NEXT launch in that directory, and the path there is not guaranteed to
|
|
65
|
+
// be byte-identical — a trailing separator (a `/cd sub/` leaves one) or a
|
|
66
|
+
// symlinked route to the project used to make a lexical `!==` silently drop the
|
|
67
|
+
// carried-over summary. realpath is the comparison; a missing dir falls back to
|
|
68
|
+
// the resolved lexical path so a deleted project still matches its record.
|
|
69
|
+
function sameDir(a, b) {
|
|
70
|
+
const x = String(a ?? '');
|
|
71
|
+
const y = String(b ?? '');
|
|
72
|
+
if (x === y)
|
|
73
|
+
return true;
|
|
74
|
+
if (!x || !y)
|
|
75
|
+
return false;
|
|
76
|
+
const real = (p) => {
|
|
77
|
+
const abs = path.resolve(p);
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync(abs);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return abs;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
return real(x) === real(y);
|
|
86
|
+
}
|
|
63
87
|
/** Remove the handoff once it has been posted (or is stale). Best-effort. */
|
|
64
88
|
export function clearProviderHandoff() {
|
|
65
89
|
try {
|
package/dist/qwen-net-capture.js
CHANGED
|
@@ -256,6 +256,14 @@ export function isGuestLimitText(body) {
|
|
|
256
256
|
const b = String(body || '');
|
|
257
257
|
if (!b)
|
|
258
258
|
return false;
|
|
259
|
+
// Same rule as isRateLimitText / isServerBusyText: a normal ANSWER may
|
|
260
|
+
// MENTION the guest limit (the model explaining Qwen's guest mode, a pasted
|
|
261
|
+
// notice). Only an error payload or a body with NO answer content can be the
|
|
262
|
+
// real notice; otherwise ask() would fail fast with "log in" and throw the
|
|
263
|
+
// answer away.
|
|
264
|
+
const errNotice = /event:\s*error|"type"\s*:\s*"error"|"error"\s*:/i.test(b);
|
|
265
|
+
if (!errNotice && hasAnswerContent(b))
|
|
266
|
+
return false;
|
|
259
267
|
return /guest chat limit|guest.*limit|log in to continue|гостевой лимит/i.test(b);
|
|
260
268
|
}
|
|
261
269
|
export function isServerBusyText(body) {
|
package/dist/scheduler.js
CHANGED
|
@@ -41,6 +41,17 @@ export function formatInterval(ms) {
|
|
|
41
41
|
return ms / 1000 + 's';
|
|
42
42
|
return ms + 'ms';
|
|
43
43
|
}
|
|
44
|
+
// The DAY rule of standard cron: when BOTH day-of-month and day-of-week are
|
|
45
|
+
// restricted they are ORed (`0 9 1 * 1` = the 1st OR any Monday); otherwise
|
|
46
|
+
// they are plain ANDed (an unrestricted field matches every day). ANDing a
|
|
47
|
+
// restricted pair made the job fire only when the two coincided.
|
|
48
|
+
function dayMatches(c, date) {
|
|
49
|
+
const domOk = c.dom.has(date.getDate());
|
|
50
|
+
const dowOk = c.dow.has(date.getDay());
|
|
51
|
+
if (c.domRestricted && c.dowRestricted)
|
|
52
|
+
return domOk || dowOk;
|
|
53
|
+
return domOk && dowOk;
|
|
54
|
+
}
|
|
44
55
|
// Parse one cron field ("*", "a-b", "*/n", "a,b", "a-b/n") into a value set.
|
|
45
56
|
function parseCronField(field, min, max) {
|
|
46
57
|
const out = new Set();
|
|
@@ -95,19 +106,26 @@ export function parseCron(expr) {
|
|
|
95
106
|
const dow = new Set();
|
|
96
107
|
for (const d of dowRaw)
|
|
97
108
|
dow.add(d === 7 ? 0 : d);
|
|
98
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
minute,
|
|
111
|
+
hour,
|
|
112
|
+
dom,
|
|
113
|
+
month,
|
|
114
|
+
dow,
|
|
115
|
+
domRestricted: parts[2].trim() !== '*',
|
|
116
|
+
dowRestricted: parts[4].trim() !== '*',
|
|
117
|
+
};
|
|
99
118
|
}
|
|
100
|
-
// Does the given Date match the cron expression?
|
|
101
|
-
//
|
|
119
|
+
// Does the given Date match the cron expression? The day rule is standard
|
|
120
|
+
// cron: dom/dow are ORed when BOTH are restricted, ANDed otherwise.
|
|
102
121
|
export function cronMatches(expr, date) {
|
|
103
122
|
const c = parseCron(expr);
|
|
104
123
|
if (!c)
|
|
105
124
|
return false;
|
|
106
125
|
return (c.minute.has(date.getMinutes()) &&
|
|
107
126
|
c.hour.has(date.getHours()) &&
|
|
108
|
-
c.dom.has(date.getDate()) &&
|
|
109
127
|
c.month.has(date.getMonth() + 1) &&
|
|
110
|
-
c
|
|
128
|
+
dayMatches(c, date));
|
|
111
129
|
}
|
|
112
130
|
// Next epoch ms at which the expression fires, strictly after `fromMs`. Scans
|
|
113
131
|
// minute by minute (bounded to ~4 years); a one-time cost when a job is
|
|
@@ -125,9 +143,8 @@ export function nextCronTime(expr, fromMs) {
|
|
|
125
143
|
while (d.getTime() <= limit) {
|
|
126
144
|
if (c.minute.has(d.getMinutes()) &&
|
|
127
145
|
c.hour.has(d.getHours()) &&
|
|
128
|
-
c.dom.has(d.getDate()) &&
|
|
129
146
|
c.month.has(d.getMonth() + 1) &&
|
|
130
|
-
c
|
|
147
|
+
dayMatches(c, d)) {
|
|
131
148
|
return d.getTime();
|
|
132
149
|
}
|
|
133
150
|
d.setMinutes(d.getMinutes() + 1);
|
package/dist/sessions.js
CHANGED
|
@@ -135,13 +135,17 @@ export function loadLastSession(workdir = '', provider) {
|
|
|
135
135
|
try {
|
|
136
136
|
const index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf-8'));
|
|
137
137
|
const byWorkdir = index && index.byWorkdir ? index.byWorkdir : {};
|
|
138
|
-
|
|
138
|
+
// A denylisted id is useless here: its subagent chat is never saved as a
|
|
139
|
+
// session, so a stale entry (recorded for a chat merely SEEN) would make the
|
|
140
|
+
// loop run out of candidates — even though the project's real session is
|
|
141
|
+
// right there on disk. Filter them out instead of `continue`-ing past them,
|
|
142
|
+
// then fall back to the newest saved session of this workdir so a stale
|
|
143
|
+
// index cannot make a saved chat unresumable.
|
|
139
144
|
const internal = readInternal();
|
|
145
|
+
const candidates = [byWorkdir[workdir], index.last].filter((x) => Boolean(x) && !internal.has(String(x)));
|
|
140
146
|
for (const id of candidates) {
|
|
141
147
|
// The session file may have been deleted — the index is then stale and
|
|
142
148
|
// the session cannot be restored. We try the next candidate.
|
|
143
|
-
if (internal.has(id))
|
|
144
|
-
continue;
|
|
145
149
|
const s = readSession(id);
|
|
146
150
|
if (!s)
|
|
147
151
|
continue;
|
|
@@ -151,7 +155,9 @@ export function loadLastSession(workdir = '', provider) {
|
|
|
151
155
|
continue;
|
|
152
156
|
return s;
|
|
153
157
|
}
|
|
154
|
-
|
|
158
|
+
const fallback = listSessions().find((s) => (!workdir || s.workdir === workdir) &&
|
|
159
|
+
(!provider || (s.provider || 'deepseek') === provider));
|
|
160
|
+
return fallback || null;
|
|
155
161
|
}
|
|
156
162
|
catch {
|
|
157
163
|
return null;
|
package/dist/shell.js
CHANGED
|
@@ -12,12 +12,25 @@ export function assertCommandInsideRoot(root, command) {
|
|
|
12
12
|
// eval re-parses its argument, so a `cd` hidden inside a string is invisible
|
|
13
13
|
// to the static scan; refusing eval together with cd is the honest answer
|
|
14
14
|
// instead of pretending the barrier still holds.
|
|
15
|
-
|
|
15
|
+
//
|
|
16
|
+
// The test is on COMMAND POSITIONS, not on raw substrings: a bare
|
|
17
|
+
// `/eval/`+/cd/ matched ordinary text like `npx tsx --test
|
|
18
|
+
// test/goal-eval-unparsable.test.ts` (which DOES contain `cd` elsewhere, e.g.
|
|
19
|
+
// `cd proj && …`), so a legitimate command — even `npm test` — was rejected
|
|
20
|
+
// with a sandbox error. eval only re-parses when it is RUN, i.e. at the start
|
|
21
|
+
// of a command or after a separator, optionally via `sh -c eval …`.
|
|
22
|
+
const evalAtCommand = /(?:^|[;&|(]\s*|\bsh\s+-c\s+['"]?)\s*eval\b/;
|
|
23
|
+
if (evalAtCommand.test(cmd) && /\bcd\b/.test(cmd)) {
|
|
16
24
|
throw new Error('Sandbox: eval with cd is forbidden');
|
|
17
25
|
}
|
|
18
26
|
// `(` and backticks open a subshell / command substitution where a `cd` is
|
|
19
27
|
// still executed; include them in the prefix so the scan sees it.
|
|
20
|
-
|
|
28
|
+
//
|
|
29
|
+
// `\s*` (not `\s+`): a shell accepts `cd` GLUED to its target — `cd..`,
|
|
30
|
+
// `cd$HOME`, `cd../..` — and runs it as a real directory change. Requiring
|
|
31
|
+
// whitespace skipped those forms, so a glued `cd../..` walked out of the
|
|
32
|
+
// root with the guard never firing.
|
|
33
|
+
const cdRe = /(?:^|[;&|(\s`])(?:cd|pushd)\s*([^;&|)]+)/gi;
|
|
21
34
|
let m;
|
|
22
35
|
while ((m = cdRe.exec(cmd))) {
|
|
23
36
|
const raw = m[1].trim().replace(/^["']|["']$/g, '');
|
|
@@ -118,10 +131,18 @@ const BG_PROCS = new Map();
|
|
|
118
131
|
const BG_MAX_BUFFER = 1024 * 1024;
|
|
119
132
|
function bgTrim(p) {
|
|
120
133
|
if (p.out.length > BG_MAX_BUFFER) {
|
|
134
|
+
// Never START the kept window on a low surrogate: the cut slices a JS
|
|
135
|
+
// string, and an emoji / astral CJK char is TWO UTF-16 units, so a cut
|
|
136
|
+
// between them left a lone half at the head of the buffer that pollBackground
|
|
137
|
+
// hands to the model (same rule as truncateToolResult / capReport).
|
|
138
|
+
let start = p.out.length - BG_MAX_BUFFER;
|
|
139
|
+
const first = p.out.charCodeAt(start);
|
|
140
|
+
if (first >= 0xdc00 && first <= 0xdfff)
|
|
141
|
+
start++;
|
|
121
142
|
p.out =
|
|
122
143
|
'[...earlier output trimmed]' +
|
|
123
144
|
String.fromCharCode(10) +
|
|
124
|
-
p.out.slice(
|
|
145
|
+
p.out.slice(start);
|
|
125
146
|
}
|
|
126
147
|
}
|
|
127
148
|
/** Start a shell command in the background. Returns a short id. */
|
|
@@ -201,6 +222,22 @@ export function killBackground(id) {
|
|
|
201
222
|
return `Failed to kill ${id}: ${e.message}`;
|
|
202
223
|
}
|
|
203
224
|
BG_PROCS.delete(id);
|
|
225
|
+
// SIGTERM is only a REQUEST. A shell that traps it (a wrapper script, a
|
|
226
|
+
// daemon that cleans up on TERM) ignores the signal, and the old code then
|
|
227
|
+
// forgot the id and reported "Killed" while the process kept running as an
|
|
228
|
+
// orphan. Give it a moment, then SIGKILL the group (the shell's own trap
|
|
229
|
+
// cannot block that). Detached from the caller so killBackground stays sync.
|
|
230
|
+
const pid = p.child.pid;
|
|
231
|
+
if (process.platform !== 'win32' && pid) {
|
|
232
|
+
const t = setTimeout(() => {
|
|
233
|
+
try {
|
|
234
|
+
process.kill(-pid, 'SIGKILL');
|
|
235
|
+
}
|
|
236
|
+
catch { }
|
|
237
|
+
}, 1500);
|
|
238
|
+
if (typeof t.unref === 'function')
|
|
239
|
+
t.unref();
|
|
240
|
+
}
|
|
204
241
|
return `Killed background process ${id}.`;
|
|
205
242
|
}
|
|
206
243
|
/** Kill every background process — called on exit so no orphan survives. */
|
package/dist/statusline.js
CHANGED
|
@@ -52,6 +52,15 @@ export function runStatusLineCommand(workdir, command, info, timeoutMs = 4000) {
|
|
|
52
52
|
clearTimeout(timer);
|
|
53
53
|
finish();
|
|
54
54
|
});
|
|
55
|
+
// 'end' on OUR stdout fires as soon as the writer closes the pipe. 'close'
|
|
56
|
+
// waits for the pipes to drain, so a command that backgrounds a daemon (a
|
|
57
|
+
// watcher, `npm run dev &`) — whose grandchild inherits the pipe — kept the
|
|
58
|
+
// promise pending until the 4s timeout on EVERY tick even though the line
|
|
59
|
+
// had already been printed.
|
|
60
|
+
child.stdout?.on('end', () => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
finish();
|
|
63
|
+
});
|
|
55
64
|
child.on('close', () => {
|
|
56
65
|
clearTimeout(timer);
|
|
57
66
|
finish();
|
package/dist/tool-summary.js
CHANGED
|
@@ -7,7 +7,16 @@
|
|
|
7
7
|
/** Collapse whitespace and clip to `max` chars (single line). */
|
|
8
8
|
function oneLine(s, max = 80) {
|
|
9
9
|
const t = String(s).replace(/\s+/g, ' ').trim();
|
|
10
|
-
|
|
10
|
+
if (t.length <= max)
|
|
11
|
+
return t;
|
|
12
|
+
// Never cut a surrogate PAIR in half: the log line is shown to the operator
|
|
13
|
+
// (and captured in the transcript), and a lone high surrogate at the end is
|
|
14
|
+
// a broken code point. Same rule as truncateToolResult / capReport.
|
|
15
|
+
let cut = max - 1;
|
|
16
|
+
const last = t.charCodeAt(cut - 1);
|
|
17
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
18
|
+
cut--;
|
|
19
|
+
return t.slice(0, cut) + '…';
|
|
11
20
|
}
|
|
12
21
|
function str(v) {
|
|
13
22
|
return typeof v === 'string' ? v : '';
|
package/dist/tools.js
CHANGED
|
@@ -10,6 +10,17 @@ import { createExtraTools } from './extraTools.js';
|
|
|
10
10
|
// user-facing. They must be in ENGLISH (like Claude Code / Codex). Only the
|
|
11
11
|
// strings the OPERATOR sees (help, spinner, service messages) are localized —
|
|
12
12
|
// those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
|
|
13
|
+
// Clip a long line without leaving a lone HIGH surrogate: the cut is in
|
|
14
|
+
// UTF-16 units, so an emoji / astral CJK char on the boundary would lose its
|
|
15
|
+
// low half and reach the model as a broken code point (same rule as
|
|
16
|
+
// truncateToolResult / capReport / summarizeDiagnostics / bgTrim).
|
|
17
|
+
function clipLine(l, max) {
|
|
18
|
+
let cut = max;
|
|
19
|
+
const last = l.charCodeAt(cut - 1);
|
|
20
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
21
|
+
cut--;
|
|
22
|
+
return l.slice(0, cut);
|
|
23
|
+
}
|
|
13
24
|
export function createTools(workdir, { undo, todos, readOnly = false, subagents = false, } = {}) {
|
|
14
25
|
const root = path.resolve(workdir);
|
|
15
26
|
const safe = (p) => safePath(root, p);
|
|
@@ -60,7 +71,8 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
|
|
|
60
71
|
const baseTools = [
|
|
61
72
|
{
|
|
62
73
|
name: 'Read',
|
|
63
|
-
description: 'Read a file. Optional: offset and limit (lines
|
|
74
|
+
description: 'Read a file. Optional: offset and limit (lines, BOTH 1-BASED: ' +
|
|
75
|
+
'offset=5 starts at line 5, limit=10 reads ten lines). ' +
|
|
64
76
|
'numbered=true adds cat -n style line numbers (1-based) for reference ' +
|
|
65
77
|
'only — do NOT copy the numbers into Edit old_string. ' +
|
|
66
78
|
'Lines longer than 2000 chars are truncated. ' +
|
|
@@ -106,10 +118,17 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
|
|
|
106
118
|
if (content === '')
|
|
107
119
|
return '(file is empty)';
|
|
108
120
|
const lines = content.split('\n');
|
|
109
|
-
|
|
110
|
-
|
|
121
|
+
// `offset`/`limit` are 1-BASED, like sed / `git blame -L` and the
|
|
122
|
+
// `numbered=true` output this tool prints. Treating them as 0-based
|
|
123
|
+
// array slices silently shifted every window by one line and made
|
|
124
|
+
// `offset: 1` skip the first line. A non-positive or non-numeric
|
|
125
|
+
// offset means "from the start".
|
|
126
|
+
const off = Math.max(1, Math.floor(Number(offset) || 1));
|
|
127
|
+
const start = off - 1;
|
|
128
|
+
const lim = Math.floor(Number(limit));
|
|
129
|
+
const end = Number.isFinite(lim) && lim > 0 ? start + lim : lines.length;
|
|
111
130
|
const slice = lines.slice(start, end);
|
|
112
|
-
const clip = (l) => l.length > 2000 ? l
|
|
131
|
+
const clip = (l) => l.length > 2000 ? clipLine(l, 2000) + ' …[line truncated]' : l;
|
|
113
132
|
if (!numbered)
|
|
114
133
|
return slice.map(clip).join('\n');
|
|
115
134
|
const width = String(start + slice.length).length;
|
package/dist/transcript.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
function transcriptErrorLine(e) {
|
|
4
|
+
return `transcript: failed to create file: ${e.message}`;
|
|
5
|
+
}
|
|
3
6
|
export class Transcript {
|
|
4
7
|
enabled;
|
|
5
8
|
file;
|
|
@@ -17,7 +20,14 @@ export class Transcript {
|
|
|
17
20
|
try {
|
|
18
21
|
fs.mkdirSync(dir, { recursive: true });
|
|
19
22
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
20
|
-
|
|
23
|
+
// `sessionName` is the LABEL of the working directory (dirLabel), so a
|
|
24
|
+
// name like `a/b` or `C:\x` would nest a subdirectory or break the
|
|
25
|
+
// path outright: the stream then failed with ENOENT and no transcript
|
|
26
|
+
// at all was written. Keep the name a plain file-name fragment.
|
|
27
|
+
const safeName = String(sessionName)
|
|
28
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
|
|
29
|
+
.trim() || 'session';
|
|
30
|
+
this.file = path.join(dir, `${safeName}-${stamp}.jsonl`);
|
|
21
31
|
this.stream = fs.createWriteStream(this.file, { flags: 'a' });
|
|
22
32
|
// Write errors (disk full, file deleted, etc.) arrive as an 'error'
|
|
23
33
|
// event; without a listener this is an uncaught exception.
|
|
@@ -27,10 +37,12 @@ export class Transcript {
|
|
|
27
37
|
});
|
|
28
38
|
}
|
|
29
39
|
catch (e) {
|
|
30
|
-
console.error(
|
|
40
|
+
console.error(transcriptErrorLine(e));
|
|
31
41
|
this.enabled = false;
|
|
32
42
|
}
|
|
33
43
|
}
|
|
44
|
+
/** Number of writes that hit backpressure (write() === false). */
|
|
45
|
+
backpressure = 0;
|
|
34
46
|
log(type, data = {}) {
|
|
35
47
|
const entry = {
|
|
36
48
|
ts: new Date().toISOString(),
|
|
@@ -60,8 +72,6 @@ export class Transcript {
|
|
|
60
72
|
}
|
|
61
73
|
catch { }
|
|
62
74
|
}
|
|
63
|
-
/** Number of writes that hit backpressure (write() === false). */
|
|
64
|
-
backpressure = 0;
|
|
65
75
|
// Flush the stream and resolve once the data reached the file. The callers
|
|
66
76
|
// run on process.exit paths (one-shot --task, browser error, clean exit), and
|
|
67
77
|
// end() without awaiting the callback lets the process die before the buffer
|
|
@@ -89,6 +99,6 @@ export class Transcript {
|
|
|
89
99
|
return null;
|
|
90
100
|
return ('transcript: ' +
|
|
91
101
|
this.backpressure +
|
|
92
|
-
' write(s) hit backpressure (slow disk?)
|
|
102
|
+
' write(s) hit backpressure (slow disk?)― events may be delayed');
|
|
93
103
|
}
|
|
94
104
|
}
|
package/dist/undo.js
CHANGED
|
@@ -77,31 +77,56 @@ export class UndoStore {
|
|
|
77
77
|
const history = await this._readIndex();
|
|
78
78
|
if (!history.length)
|
|
79
79
|
return { ok: false, reason: 'empty' };
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
// Walk back from the newest record and SKIP a dead one (its .bak vanished,
|
|
81
|
+
// e.g. ~/.zames/undo was pruned or a crash left a record without the file).
|
|
82
|
+
// The old code popped the dead record, failed, and never rewrote the index —
|
|
83
|
+
// so every later /undo hit the same record again and the earlier backups
|
|
84
|
+
// stayed unreachable.
|
|
85
|
+
while (history.length) {
|
|
86
|
+
const record = history.pop();
|
|
87
|
+
if (!record)
|
|
88
|
+
break;
|
|
89
|
+
const { originalPath, existed, backupFile } = record;
|
|
90
|
+
let data = null;
|
|
85
91
|
if (existed && backupFile) {
|
|
86
|
-
const data = await fs.readFile(backupFile);
|
|
87
|
-
await fs.writeFile(originalPath, data);
|
|
88
92
|
try {
|
|
89
|
-
await fs.
|
|
93
|
+
data = await fs.readFile(backupFile);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
if (e.code === 'ENOENT')
|
|
97
|
+
continue;
|
|
98
|
+
return { ok: false, reason: e.message };
|
|
90
99
|
}
|
|
91
|
-
catch { }
|
|
92
100
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
await fs.
|
|
101
|
+
try {
|
|
102
|
+
if (data) {
|
|
103
|
+
await fs.writeFile(originalPath, data);
|
|
104
|
+
try {
|
|
105
|
+
await fs.unlink(backupFile);
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
96
108
|
}
|
|
97
|
-
|
|
109
|
+
else {
|
|
110
|
+
// existed === false: the agent CREATED the file, so undoing removes
|
|
111
|
+
// it again. ENOENT is the expected case (it is already gone).
|
|
112
|
+
try {
|
|
113
|
+
await fs.unlink(originalPath);
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
}
|
|
117
|
+
await this._writeIndex(history);
|
|
118
|
+
return { ok: true, record };
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
// A real failure (permissions, a directory in place of the file): put
|
|
122
|
+
// the record back so a retry after fixing the cause still works.
|
|
123
|
+
history.push(record);
|
|
124
|
+
await this._writeIndex(history);
|
|
125
|
+
return { ok: false, reason: e.message };
|
|
98
126
|
}
|
|
99
|
-
await this._writeIndex(history);
|
|
100
|
-
return { ok: true, record };
|
|
101
|
-
}
|
|
102
|
-
catch (e) {
|
|
103
|
-
return { ok: false, reason: e.message };
|
|
104
127
|
}
|
|
128
|
+
await this._writeIndex(history);
|
|
129
|
+
return { ok: false, reason: 'empty' };
|
|
105
130
|
}
|
|
106
131
|
async list(count = 10) {
|
|
107
132
|
const history = await this._readIndex();
|
package/dist/web.js
CHANGED
|
@@ -38,10 +38,17 @@ export function htmlToText(html) {
|
|
|
38
38
|
pendingHighSurrogate = null;
|
|
39
39
|
// Remove blocks we don't need
|
|
40
40
|
let s = html
|
|
41
|
+
// A tag that never CLOSES (a truncated body, or HTML the regex cannot
|
|
42
|
+
// pair) used to leak its raw JS/CSS into the text: also drop everything
|
|
43
|
+
// from an unclosed opening tag to the end of the document.
|
|
41
44
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
45
|
+
.replace(/<script\b[\s\S]*$/i, '')
|
|
42
46
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
47
|
+
.replace(/<style\b[\s\S]*$/i, '')
|
|
43
48
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
|
|
49
|
+
.replace(/<noscript\b[\s\S]*$/i, '')
|
|
44
50
|
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
|
51
|
+
.replace(/<svg\b[\s\S]*$/i, '')
|
|
45
52
|
.replace(/<!--[\s\S]*?-->/g, '');
|
|
46
53
|
// Links: <a href="URL">text</a> → text (URL)
|
|
47
54
|
s = s.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => {
|
|
@@ -125,6 +132,49 @@ export function parseLegacyIpv4(host) {
|
|
|
125
132
|
n += nums[nums.length - 1];
|
|
126
133
|
return n >>> 0;
|
|
127
134
|
}
|
|
135
|
+
// Collapse an IPv6 literal to the canonical `a:b:c:d:e:f:g:h` hex form so the
|
|
136
|
+
// prefix checks below see the SAME address whether it was written short
|
|
137
|
+
// (`::1`, `::ffff:7f00:1`) or fully expanded (`0:0:0:0:0:0:0:1`,
|
|
138
|
+
// `0:0:0:0:0:ffff:7f00:1`). A text-prefix match treated the expanded form as
|
|
139
|
+
// PUBLIC, which let the SSRF guard reach loopback. Returns null for a non-IPv6
|
|
140
|
+
// string or a malformed literal.
|
|
141
|
+
function expandIpv6(ip) {
|
|
142
|
+
const s = ip.toLowerCase();
|
|
143
|
+
if (!s.includes(':'))
|
|
144
|
+
return null;
|
|
145
|
+
// An embedded dotted IPv4 tail (::ffff:127.0.0.1) is folded into two hex
|
|
146
|
+
// groups first, so the split below has a uniform shape.
|
|
147
|
+
const dotted = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
148
|
+
let body = s;
|
|
149
|
+
if (dotted) {
|
|
150
|
+
const [a, b, c, d] = dotted.slice(2).map(Number);
|
|
151
|
+
if ([a, b, c, d].some((n) => n > 255))
|
|
152
|
+
return null;
|
|
153
|
+
const hi = ((a << 8) | b).toString(16);
|
|
154
|
+
const lo = ((c << 8) | d).toString(16);
|
|
155
|
+
body = dotted[1] + hi + ':' + lo;
|
|
156
|
+
}
|
|
157
|
+
const halves = body.split('::');
|
|
158
|
+
if (halves.length > 2)
|
|
159
|
+
return null;
|
|
160
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
161
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
162
|
+
const groups = [];
|
|
163
|
+
for (const g of [...head, ...tail]) {
|
|
164
|
+
if (!/^[0-9a-f]{1,4}$/.test(g))
|
|
165
|
+
return null;
|
|
166
|
+
groups.push(g);
|
|
167
|
+
}
|
|
168
|
+
const missing = 8 - groups.length - (halves.length === 2 ? 0 : 0);
|
|
169
|
+
if (halves.length === 2) {
|
|
170
|
+
if (missing < 0)
|
|
171
|
+
return null;
|
|
172
|
+
return [...head, ...Array(missing).fill('0'), ...tail].map((g) => parseInt(g, 16));
|
|
173
|
+
}
|
|
174
|
+
if (groups.length !== 8)
|
|
175
|
+
return null;
|
|
176
|
+
return groups.map((g) => parseInt(g, 16));
|
|
177
|
+
}
|
|
128
178
|
export function isPrivateIp(ip) {
|
|
129
179
|
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
130
180
|
if (v4) {
|
|
@@ -158,6 +208,36 @@ export function isPrivateIp(ip) {
|
|
|
158
208
|
}
|
|
159
209
|
}
|
|
160
210
|
const low = ip.toLowerCase();
|
|
211
|
+
// Judge the EXPANDED form so `0:0:0:0:0:0:0:1` is treated exactly like
|
|
212
|
+
// `::1` (a text-prefix match called the expanded literal public).
|
|
213
|
+
const v6 = expandIpv6(ip);
|
|
214
|
+
if (v6) {
|
|
215
|
+
const allZero = v6.every((g) => g === 0);
|
|
216
|
+
const loopback = v6.slice(0, 7).every((g) => g === 0) && v6[7] === 1;
|
|
217
|
+
if (allZero || loopback)
|
|
218
|
+
return true;
|
|
219
|
+
const g0 = v6[0];
|
|
220
|
+
// fe80::/10 link-local, fec0::/10 deprecated site-local.
|
|
221
|
+
if ((g0 & 0xffc0) === 0xfe80 || (g0 & 0xffc0) === 0xfec0)
|
|
222
|
+
return true;
|
|
223
|
+
// fc00::/7 unique-local.
|
|
224
|
+
if ((g0 & 0xfe00) === 0xfc00)
|
|
225
|
+
return true;
|
|
226
|
+
// ff00::/8 multicast is not a public unicast destination either.
|
|
227
|
+
if ((g0 & 0xff00) === 0xff00)
|
|
228
|
+
return true;
|
|
229
|
+
// IPv4-mapped ::ffff:a.b.c.d — judge the embedded address.
|
|
230
|
+
const mappedV4 = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 0xffff;
|
|
231
|
+
if (mappedV4) {
|
|
232
|
+
return isPrivateIp((v6[6] >> 8) +
|
|
233
|
+
'.' +
|
|
234
|
+
(v6[6] & 0xff) +
|
|
235
|
+
'.' +
|
|
236
|
+
(v6[7] >> 8) +
|
|
237
|
+
'.' +
|
|
238
|
+
(v6[7] & 0xff));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
161
241
|
if (low === '::1' || low === '::')
|
|
162
242
|
return true;
|
|
163
243
|
// IPv6 link-local is fe80::/10, i.e. fe80..febf (the old `startsWith('fe80')`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.78.
|
|
3
|
+
"version": "2.78.3",
|
|
4
4
|
"description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|