zames_pro 2.79.0 → 2.80.0

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.80.0] - 2026-10-20
11
+
12
+ ### Fixed
13
+
14
+ - **commands:** keep an unsupplied position as written in a custom command
15
+ - **subagent:** count a subagent run only once it actually starts
16
+ - **attach:** do not cut an inlined @file inside a surrogate pair
17
+ - **checkpoint:** a bare out-of-range rewind index no longer matches a stamp prefix
18
+ - **git:** split a space-separated paths argument in GitAdd
19
+ - **mcp:** keep an MCP tool result inside its size cap
20
+ - **hooks:** never cut a hook denial or notice inside a surrogate pair
21
+ - **sessions:** escape a literal underscore in the session file name
22
+
10
23
  ## [2.79.0] - 2026-10-19
11
24
 
12
25
  ### Fixed
@@ -94,7 +94,15 @@ export async function inlineAtRefs(workdir, text) {
94
94
  let body = raw;
95
95
  let note = '';
96
96
  if (body.length > AT_REF_FILE_CAP) {
97
- body = body.slice(0, AT_REF_FILE_CAP);
97
+ let cut = AT_REF_FILE_CAP;
98
+ // Never end the inlined block on a lone HIGH surrogate: the cut is in
99
+ // UTF-16 units, so an emoji / astral CJK char on the boundary would
100
+ // reach the model as a broken code point (same rule as
101
+ // truncateToolResult / capReport / cutHookText).
102
+ const last = body.charCodeAt(cut - 1);
103
+ if (last >= 0xd800 && last <= 0xdbff)
104
+ cut--;
105
+ body = body.slice(0, cut);
98
106
  note = NL + NL + '(truncated at ' + AT_REF_FILE_CAP + ' chars)';
99
107
  }
100
108
  total += body.length;
@@ -50,10 +50,15 @@ export function pickRecord(records, selector) {
50
50
  return null;
51
51
  if (/^\d+$/.test(sel)) {
52
52
  const n = Number(sel);
53
- // A small number is an index (1 = newest). An out-of-range number is
54
- // still tried as a stamp prefix below, so `/rewind 1730000` works.
53
+ // A small number is an index (1 = newest).
55
54
  if (n >= 1 && n <= records.length)
56
55
  return records[n - 1];
56
+ // A stamp is epoch-ms (~13 digits), but older files and tests may hold
57
+ // a SHORT one, so the prefix form stays. What must NOT happen is a bare
58
+ // out-of-range INDEX matching a stamp by its first digit (`/rewind 9`
59
+ // with 5 checkpoints): the prefix form therefore needs TWO digits.
60
+ if (sel.length < 2)
61
+ return null;
57
62
  }
58
63
  return records.find((r) => String(r.stamp).startsWith(sel)) || null;
59
64
  }
package/dist/commands.js CHANGED
@@ -773,7 +773,11 @@ function replaceDollarTokens(s, parts, names) {
773
773
  continue;
774
774
  }
775
775
  if (isAllDigits(tok)) {
776
- res += parts[Number(tok) - 1] ?? '';
776
+ const idx = Number(tok) - 1;
777
+ // A position that was NOT supplied stays as written: `$100` in prose
778
+ // is a PRICE, not "position 100", and replacing it with an empty
779
+ // string silently deleted the number from the prompt.
780
+ res += idx >= 0 && idx < parts.length ? parts[idx] : '$' + tok;
777
781
  }
778
782
  else {
779
783
  const idx = names.indexOf(tok);
package/dist/gitTools.js CHANGED
@@ -44,6 +44,39 @@ export function runGitArgs(args, cwd, timeout = 15_000) {
44
44
  });
45
45
  });
46
46
  }
47
+ // Split a `paths` argument into individual pathspecs. The parameter is
48
+ // declared as a STRING, so models send "a.txt b.txt" or a quoted path with a
49
+ // space ("my file.txt"); git needs each path as its OWN argument.
50
+ export function splitPaths(input) {
51
+ const out = [];
52
+ const s = String(input || '');
53
+ let cur = '';
54
+ let quote = null;
55
+ for (let i = 0; i < s.length; i++) {
56
+ const ch = s[i];
57
+ if (quote) {
58
+ if (ch === quote)
59
+ quote = null;
60
+ else
61
+ cur += ch;
62
+ continue;
63
+ }
64
+ if (ch === '"' || ch === "'") {
65
+ quote = ch;
66
+ continue;
67
+ }
68
+ if (ch === ' ' || ch === '\t' || ch === '\n') {
69
+ if (cur)
70
+ out.push(cur);
71
+ cur = '';
72
+ continue;
73
+ }
74
+ cur += ch;
75
+ }
76
+ if (cur)
77
+ out.push(cur);
78
+ return out;
79
+ }
47
80
  export async function getGitContext(workdir) {
48
81
  const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], workdir, 5000);
49
82
  if (probe.trim() !== 'true')
@@ -215,16 +248,27 @@ export function createGitTools(workdir) {
215
248
  if (err)
216
249
  return err;
217
250
  if (paths && String(paths).trim()) {
218
- const list = (Array.isArray(paths) ? paths : [paths])
251
+ // `paths` is declared as a STRING, so the model sends
252
+ // "a.txt b.txt". Passing it as ONE pathspec made git answer
253
+ // `did not match any files` and staged nothing. Split on
254
+ // whitespace, honour quotes (a path may contain a space), and
255
+ // keep the array form working.
256
+ const list = (Array.isArray(paths) ? paths : splitPaths(String(paths)))
219
257
  .map((v) => String(v).trim())
220
258
  .filter(Boolean);
221
259
  for (const p of list) {
222
260
  if (p.startsWith('-'))
223
261
  return `Error: invalid path: ${p}`;
224
262
  }
225
- return runGitArgs(['add', '--', ...list], workdir, 20_000);
263
+ const staged = await runGitArgs(['add', '--', ...list], workdir, 20_000);
264
+ // A successful `git add` prints NOTHING; the shared runner then
265
+ // answers the internal sentinel, which reads as a failure.
266
+ return staged.trim() === NO_OUTPUT_SENTINEL
267
+ ? 'Staged: ' + list.join(' ')
268
+ : staged;
226
269
  }
227
- return runGitArgs(['add', '-A'], workdir, 20_000);
270
+ const all = await runGitArgs(['add', '-A'], workdir, 20_000);
271
+ return all.trim() === NO_OUTPUT_SENTINEL ? 'Staged all changes.' : all;
228
272
  },
229
273
  },
230
274
  {
package/dist/hooks.js CHANGED
@@ -18,6 +18,19 @@ const HOOK_DENIAL_CAP = 4000;
18
18
  // Combined stdout of a PostToolUse / lifecycle hook. It lands in a tool result
19
19
  // or in the chat, so it is a notice, not a log dump.
20
20
  const HOOK_OUTPUT_CAP = 8000;
21
+ // Cut a hook string by UTF-16 units WITHOUT leaving a lone HIGH surrogate at
22
+ // the end: the text becomes a tool result or a chat notice, and a broken code
23
+ // point there is the same bug truncated elsewhere (same rule as
24
+ // truncateToolResult / capReport / summarizeDiagnostics).
25
+ function cutHookText(s, max) {
26
+ if (s.length <= max)
27
+ return s;
28
+ let cut = Math.max(0, max);
29
+ const last = s.charCodeAt(cut - 1);
30
+ if (last >= 0xd800 && last <= 0xdbff)
31
+ cut--;
32
+ return s.slice(0, cut);
33
+ }
21
34
  export function hooksConfigPath(workdir) {
22
35
  return path.join(workdir, '.zames', 'hooks.json');
23
36
  }
@@ -150,7 +163,7 @@ export async function runPreToolUse(hooks, tool, args, workdir) {
150
163
  const reason = (r.stderr || r.stdout).trim();
151
164
  // The denial text becomes a TOOL RESULT the model reads: a hook that
152
165
  // dumps a log must not flood the context with one call.
153
- const capped = reason.slice(0, HOOK_DENIAL_CAP);
166
+ const capped = cutHookText(reason, HOOK_DENIAL_CAP);
154
167
  const suffix = reason.length > HOOK_DENIAL_CAP
155
168
  ? '\n… [denial truncated: ' +
156
169
  (reason.length - HOOK_DENIAL_CAP) +
@@ -198,14 +211,14 @@ async function runEventHooks(hooks, event, workdir, tool, args, result) {
198
211
  // hundreds of KB and flood the context.
199
212
  if (total >= HOOK_OUTPUT_CAP)
200
213
  break;
201
- const piece = out.slice(0, HOOK_OUTPUT_CAP - total);
214
+ const piece = cutHookText(out, HOOK_OUTPUT_CAP - total);
202
215
  total += piece.length;
203
216
  parts.push(piece);
204
217
  }
205
218
  const joined = parts.join(String.fromCharCode(10));
206
219
  if (joined.length <= HOOK_OUTPUT_CAP)
207
220
  return joined;
208
- return (joined.slice(0, HOOK_OUTPUT_CAP) +
221
+ return (cutHookText(joined, HOOK_OUTPUT_CAP) +
209
222
  String.fromCharCode(10) +
210
223
  '… [hook output truncated]');
211
224
  }
package/dist/mcp.js CHANGED
@@ -183,7 +183,14 @@ export function truncateMcpResult(s, limit = MCP_RESULT_LIMIT) {
183
183
  const str = String(s);
184
184
  if (str.length <= limit)
185
185
  return str;
186
- let head = str.slice(0, limit);
186
+ // The MARKER is part of the result, so the head must leave room for it:
187
+ // appending it to a limit-long head produced a result WIDER than the cap
188
+ // (the cap exists to bound the context a single MCP call can consume).
189
+ // Reserve the WORST-CASE marker (a 10-digit count), so no result can
190
+ // exceed the cap whatever the omitted length turns out to be.
191
+ const MARKER = '\n[...truncated 9999999999 chars]';
192
+ const budget = Math.max(0, limit - MARKER.length);
193
+ let head = str.slice(0, budget);
187
194
  // Never leave a lone HIGH surrogate at the cut: an emoji / astral CJK char
188
195
  // sits on two UTF-16 units, and cutting between them hands the model a
189
196
  // broken code point (same rule as truncateToolResult / capReport).
@@ -194,7 +201,7 @@ export function truncateMcpResult(s, limit = MCP_RESULT_LIMIT) {
194
201
  }
195
202
  // Prefer a line boundary so a cut does not split a line in half.
196
203
  const nl = head.lastIndexOf(String.fromCharCode(10));
197
- const cut = nl > limit * 0.5 ? head.slice(0, nl + 1) : head;
204
+ const cut = nl > budget * 0.5 ? head.slice(0, nl + 1) : head;
198
205
  const omitted = str.length - cut.length;
199
206
  return cut + `\n[...truncated ${omitted} chars]`;
200
207
  }
package/dist/sessions.js CHANGED
@@ -55,7 +55,10 @@ function safeName(id) {
55
55
  const s = String(id);
56
56
  let out = '';
57
57
  for (const ch of s) {
58
- if (/[a-zA-Z0-9.-]/.test(ch))
58
+ // `_` ITSELF is the escape prefix: leaving a literal underscore in
59
+ // place made the encoding ambiguous (`a/b` -> `a_2fb` collided with the
60
+ // real id `a_2fb`), so one session silently overwrote the other.
61
+ if (/[a-zA-Z0-9.-]/.test(ch) && ch !== '_')
59
62
  out += ch;
60
63
  else
61
64
  out += '_' + ch.codePointAt(0).toString(16).padStart(2, '0');
package/dist/subagent.js CHANGED
@@ -76,7 +76,6 @@ export function createSubagentRunner(opts) {
76
76
  ' per task). Do the remaining work yourself with the other tools.',
77
77
  };
78
78
  }
79
- used++;
80
79
  // Resolve the parent chat id BEFORE anything else. The runner restores the
81
80
  // parent chat in its finally block, and that restore is what keeps the
82
81
  // subagent's isolated context from swallowing the parent's turn. If the id
@@ -98,6 +97,10 @@ export function createSubagentRunner(opts) {
98
97
  'would be lost). Do the work yourself with the other tools.',
99
98
  };
100
99
  }
100
+ // Count the run only NOW: every early exit above (budget, unknown
101
+ // parent chat id) returned BEFORE anything ran, and charging those
102
+ // attempts burned the whole budget on runs that never started.
103
+ used++;
101
104
  const subTools = buildTools(req.type === 'explore');
102
105
  const taskText = subagentPersona(req.type) + '\n\n--- SUB-TASK ---\n' + req.prompt;
103
106
  // Save the parent's send hooks: the nested loop OVERWRITES them on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.79.0",
3
+ "version": "2.80.0",
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": {