zames_pro 2.78.2 → 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,23 @@ 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
+
10
27
  ## [2.78.2] - 2026-10-18
11
28
 
12
29
  ### 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.
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 };
@@ -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/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);
@@ -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
  }
@@ -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
  //
@@ -124,6 +124,13 @@ export function decidePermission(policy, tool, args) {
124
124
  }
125
125
  const paths = collectPathCandidates(tool, args);
126
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
+ }
127
134
  if (r.tool) {
128
135
  const re = safeRegex(r.tool);
129
136
  if (!re || !re.test(tool))
package/dist/shell.js CHANGED
@@ -25,7 +25,12 @@ export function assertCommandInsideRoot(root, command) {
25
25
  }
26
26
  // `(` and backticks open a subshell / command substitution where a `cd` is
27
27
  // still executed; include them in the prefix so the scan sees it.
28
- 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;
29
34
  let m;
30
35
  while ((m = cdRe.exec(cmd))) {
31
36
  const raw = m[1].trim().replace(/^["']|["']$/g, '');
@@ -217,6 +222,22 @@ export function killBackground(id) {
217
222
  return `Failed to kill ${id}: ${e.message}`;
218
223
  }
219
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
+ }
220
241
  return `Killed background process ${id}.`;
221
242
  }
222
243
  /** Kill every background process — called on exit so no orphan survives. */
package/dist/tools.js CHANGED
@@ -71,7 +71,8 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
71
71
  const baseTools = [
72
72
  {
73
73
  name: 'Read',
74
- 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). ' +
75
76
  'numbered=true adds cat -n style line numbers (1-based) for reference ' +
76
77
  'only — do NOT copy the numbers into Edit old_string. ' +
77
78
  'Lines longer than 2000 chars are truncated. ' +
@@ -117,8 +118,15 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
117
118
  if (content === '')
118
119
  return '(file is empty)';
119
120
  const lines = content.split('\n');
120
- const start = offset ?? 0;
121
- 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;
122
130
  const slice = lines.slice(start, end);
123
131
  const clip = (l) => l.length > 2000 ? clipLine(l, 2000) + ' …[line truncated]' : l;
124
132
  if (!numbered)
@@ -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/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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.78.2",
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": {