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 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
@@ -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.
@@ -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 && st.isFile())
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
- const bodyLines = body.split(NL);
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 };
@@ -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
- const keptStamps = new Set(kept.map((r) => r.stamp));
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 (!keptStamps.has(r.stamp))
186
+ if (!keptSet.has(r))
183
187
  await fs.rm(r.file, { force: true }).catch(() => { });
184
188
  }
185
189
  writeJsonAtomic(this.indexFile, kept);
@@ -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
- if (/^(?:found\s+)?0\s+errors?\b/im.test(text))
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(text))
39
+ if (/^no errors?(?: found)?\.?$/im.test(cleanCheck))
34
40
  return null;
35
- if (/^0 problems?\b/im.test(text))
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
- const tmp = file + '.tmp-' + process.pid;
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, file);
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 || '(command produced no output)');
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
- const statusLines = statusR
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
- return g.slice(0, GOAL_CONDITION_CAP);
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
- const m = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(MET|NOT_MET|IMPOSSIBLE)/i.exec(t);
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
- parts.push(out);
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
- return parts.join(String.fromCharCode(10));
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
  }
@@ -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 (!c.name.toLowerCase().startsWith(prefix.toLowerCase())) {
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
- return server + '__' + tool.replace(/[^A-Za-z0-9_]/g, '_');
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
@@ -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 (a tool-call JSON, prose, a `respond` call) is far longer, so
173
- // anything below this is treated as a truncated/empty turn and retried.
174
- const MIN_FINISHED_ANSWER_CHARS = 4;
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
- return extractFromSse(b).length;
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 >= MIN_FINISHED_ANSWER_CHARS)
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) >= MIN_FINISHED_ANSWER_CHARS;
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) {
@@ -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
- const commands = COMMAND_KEYS.filter((k) => typeof a[k] === 'string').map((k) => a[k]);
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))
@@ -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 || '') !== (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 {
@@ -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 { minute, hour, dom, month, dow };
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? dom and dow are ANDed (the
101
- // common, predictable behavior); to schedule "every day" use `*` for one.
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.dow.has(date.getDay()));
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.dow.has(d.getDay())) {
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
- const candidates = [byWorkdir[workdir], index.last].filter((x) => Boolean(x));
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
- return null;
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
- if (/\beval\b/.test(cmd) && /\bcd\b/.test(cmd)) {
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
- const cdRe = /(?:^|[;&|(\s`])(?:cd|pushd)\s+([^;&|)]+)/gi;
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(p.out.length - BG_MAX_BUFFER);
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. */
@@ -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();
@@ -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
- return t.length > max ? t.slice(0, max - 1) + '…' : t;
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
- const start = offset ?? 0;
110
- const end = limit ? start + limit : lines.length;
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.slice(0, 2000) + ' …[line truncated]' : 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;
@@ -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
- this.file = path.join(dir, `${sessionName}-${stamp}.jsonl`);
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(`transcript: failed to create file: ${e.message}`);
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?) — events may be delayed');
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
- const record = history.pop();
81
- if (!record)
82
- return { ok: false, reason: 'empty' };
83
- const { originalPath, existed, backupFile } = record;
84
- try {
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.unlink(backupFile);
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
- else {
94
- try {
95
- await fs.unlink(originalPath);
101
+ try {
102
+ if (data) {
103
+ await fs.writeFile(originalPath, data);
104
+ try {
105
+ await fs.unlink(backupFile);
106
+ }
107
+ catch { }
96
108
  }
97
- catch { }
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.1",
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": {