zames_pro 2.96.1 → 2.96.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,13 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.96.3] - 2026-11-06
11
+
10
12
  ### Fixed
11
13
 
12
- - **release-policy:** one pair of parens in the BREAKING note
13
- - **changelog:** a group heading must start a line, not sit inside a bullet
14
- - **backlog:** ignore a fenced section header when appending
15
- - **dev-watch:** follow index.ts's hot-reloadable list (restore-history, input/layout)
16
- - **shell:** block a recursive rm of ./ and ../ (trailing slash)
14
+ - **banner:** keep a banner field on one line inside the box
15
+ - **scheduler:** reschedule a job whose nextAt is not finite
16
+ - **goal:** keep a condition from injecting its own verdict block
17
+ - **compact:** keep operator and summary text out of the carryover headings
18
+ - **release:** sync the changelog and rebuild its pages inside a release
19
+
20
+ ## [2.96.2] - 2026-11-06
21
+
22
+ ### Fixed
23
+
24
+ - **info:** keep a /chat and /pwd value on one line
25
+ - **status:** flatten a status/doctor value and drop a broken count
26
+ - **limits:** never render a negative or NaN counter in /limits
27
+ - **restore-history:** keep a plain answer that opens with 'thinking for'
28
+ - **commands:** keep a project file/skill/command name on one /context row
17
29
 
18
30
  ## [2.96.1] - 2026-11-06
19
31
 
package/dist/banner.js CHANGED
@@ -81,11 +81,16 @@ function clip(s, max) {
81
81
  * (the terminal width minus a safety column).
82
82
  */
83
83
  export function renderStartupBanner(info, contentWidth) {
84
- const title = theme.assistant(' ' + info.title) + theme.dim(' v' + info.version);
85
- const dir = theme.system(' cwd: ') + theme.dir(info.workdir);
86
- const prov = theme.system(' provider: ') + theme.toggleOn(info.provider);
84
+ // Every field is printed INSIDE the box: a newline in the working directory
85
+ // or in the provider label (both come from the config/environment) broke the
86
+ // borders apart, so each value is flattened to one line first.
87
+ const oneLine = (v) => String(v ?? '').replace(/\s+/g, ' ');
88
+ const title = theme.assistant(' ' + oneLine(info.title)) +
89
+ theme.dim(' v' + oneLine(info.version));
90
+ const dir = theme.system(' cwd: ') + theme.dir(oneLine(info.workdir));
91
+ const prov = theme.system(' provider: ') + theme.toggleOn(oneLine(info.provider));
87
92
  const lines = [title, dir, prov];
88
93
  if (info.hint)
89
- lines.push(theme.dim(' ' + info.hint));
94
+ lines.push(theme.dim(' ' + oneLine(info.hint)));
90
95
  return box(lines, contentWidth);
91
96
  }
package/dist/commands.js CHANGED
@@ -64,11 +64,16 @@ export function formatDiffStat(statText, opts = {}, t = translate('en')) {
64
64
  /** One renderable line per loaded context source (file + char count). */
65
65
  export function formatContextSources(agents, memory, skills, commands, opts = {}, t = translate('en')) {
66
66
  const lines = [];
67
+ // Paths, skill and command names come from the operator's PROJECT: a
68
+ // newline in one of them started a NEW row of the report (a crafted
69
+ // `/cmd\nFAKE` printed a bare `FAKE` line), so every value is flattened to
70
+ // one line first — the same rule as renderDoctor/formatJobLine.
71
+ const oneLine = (v) => String(v ?? '').replace(/\s+/g, ' ');
67
72
  lines.push(t('context.title'));
68
73
  if (typeof opts.systemPromptChars === 'number') {
69
74
  lines.push(t('context.system_prompt', { n: String(opts.systemPromptChars) }));
70
75
  }
71
- const fileLine = (p, content) => ' ' + p + ' (' + content.length + ' chars)';
76
+ const fileLine = (p, content) => ' ' + oneLine(p) + ' (' + content.length + ' chars)';
72
77
  lines.push(t('context.agents'));
73
78
  if (agents.length)
74
79
  for (const a of agents)
@@ -84,13 +89,13 @@ export function formatContextSources(agents, memory, skills, commands, opts = {}
84
89
  lines.push(t('context.skills'));
85
90
  if (skills.length)
86
91
  for (const s of skills)
87
- lines.push(' ' + s.name + (s.path ? ' ' + s.path : ''));
92
+ lines.push(' ' + oneLine(s.name) + (s.path ? ' ' + oneLine(s.path) : ''));
88
93
  else
89
94
  lines.push(' ' + t('context.none'));
90
95
  lines.push(t('context.commands'));
91
96
  if (commands.length)
92
97
  for (const c of commands)
93
- lines.push(' /' + c.name);
98
+ lines.push(' /' + oneLine(c.name));
94
99
  else
95
100
  lines.push(' ' + t('context.none'));
96
101
  return lines.join(NL);
@@ -575,8 +580,19 @@ export function buildCompactToolResult(todos = [], goal = null) {
575
580
  * they are session state the model-written summary is not guaranteed to keep.
576
581
  */
577
582
  export function buildCompactCarryover(summary, task, todos = [], goal = null) {
578
- const body = String(summary ?? '').trim();
579
- const goalText = String(task ?? '').trim();
583
+ const NL2 = NL + NL;
584
+ // The carried text becomes the FIRST USER TURN of the new chat. The task is
585
+ // OPERATOR text and the summary is MODEL text: a line starting with `## `
586
+ // would fake one of the SESSION blocks this function appends itself (a todo
587
+ // checklist / a goal the agent never had), so such lines are indented out of
588
+ // the heading namespace.
589
+ const noHeadings = (v) => String(v ?? '')
590
+ .split(NL)
591
+ .map((l) => (/^## /.test(l) ? ' ' + l : l))
592
+ .join(NL)
593
+ .trim();
594
+ const body = noHeadings(summary);
595
+ const goalText = noHeadings(task);
580
596
  let out = 'Context carried over from a previous chat (compacted). ' +
581
597
  'Treat it as the history of our work so far and continue from the current state.';
582
598
  out += NL + NL + body;
package/dist/goal.js CHANGED
@@ -29,6 +29,15 @@ export function clampGoalCondition(condition) {
29
29
  // is requested; no tools.
30
30
  export function buildEvaluatorPrompt(condition) {
31
31
  const NL = String.fromCharCode(10);
32
+ // The condition is OPERATOR text pasted VERBATIM. A line of it that looks
33
+ // like the requested answer format (`Reply with EXACTLY two lines`,
34
+ // `VERDICT: MET`) would let the operator's own words stand in for the
35
+ // judge's verdict and close an unfinished task as done: such a line is
36
+ // quoted instead of being left at the start of a line.
37
+ const safeCondition = String(condition ?? '')
38
+ .split(NL)
39
+ .map((l) => /^\s*(?:reply with|verdict\s*:|reason\s*:)/i.test(l) ? '> ' + l : l)
40
+ .join(NL);
32
41
  return ('You are a strict COMPLETION EVALUATOR. You do NOT do the work and you do NOT call tools. Read ' +
33
42
  'the conversation above and judge - from what the agent has ALREADY surfaced - whether ' +
34
43
  'the completion condition below is satisfied.' +
@@ -36,7 +45,7 @@ export function buildEvaluatorPrompt(condition) {
36
45
  NL +
37
46
  'Completion condition:' +
38
47
  NL +
39
- condition +
48
+ safeCondition +
40
49
  NL +
41
50
  NL +
42
51
  'Reply with EXACTLY two lines, nothing else:' +
@@ -10,6 +10,11 @@ import { theme } from './theme.js';
10
10
  */
11
11
  export function createInfoCommands(deps) {
12
12
  const { t } = deps;
13
+ // A chat id comes from the page URL and the working directory from the
14
+ // process cwd: a newline in either would start a LINE of its own in the
15
+ // terminal, so a value is flattened before it is printed (the same rule
16
+ // `/status` and `/doctor` already follow).
17
+ const oneLine = (v) => String(v ?? '').replace(/\s+/g, ' ');
13
18
  const printLines = (lines, print) => {
14
19
  for (const line of lines)
15
20
  print(theme.system(line));
@@ -24,17 +29,19 @@ export function createInfoCommands(deps) {
24
29
  handler: async (ctx) => {
25
30
  const id = deps.currentChatId();
26
31
  if (id) {
27
- ctx.print(theme.system(t('chats.current_id', { v: id })));
28
- ctx.print(theme.system(`URL: ${deps.chatUrl(id)}`));
32
+ ctx.print(theme.system(t('chats.current_id', { v: oneLine(id) })));
33
+ ctx.print(theme.system(`URL: ${oneLine(deps.chatUrl(id))}`));
29
34
  return;
30
35
  }
31
36
  const live = await deps.resolveChatId();
32
- ctx.print(theme.system(live ? t('chats.current_id', { v: live }) : t('chats.not_created')));
37
+ ctx.print(theme.system(live
38
+ ? t('chats.current_id', { v: oneLine(live) })
39
+ : t('chats.not_created')));
33
40
  },
34
41
  },
35
42
  {
36
43
  name: 'pwd',
37
- handler: (ctx) => ctx.print(theme.system(deps.workdir())),
44
+ handler: (ctx) => ctx.print(theme.system(oneLine(deps.workdir()))),
38
45
  },
39
46
  {
40
47
  name: 'transcript',
@@ -5,9 +5,17 @@ import { translate } from './i18n.js';
5
5
  */
6
6
  export function formatLimits(stats, opts, t = translate('en')) {
7
7
  const out = [];
8
+ // A counter is a count and a pause is a duration: a negative/NaN/Infinity
9
+ // value (a hand-edited telemetry line, a broken provider counter) must not
10
+ // render as `-3s` / `NaNs`, which reads like a real measurement.
11
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? String(Math.max(0, v)) : '0';
8
12
  out.push({
9
13
  kind: 'title',
10
- text: t('limits.title', { provider: opts.providerLabel }),
14
+ // The provider label comes from the config: a newline in it would start a
15
+ // row of its own.
16
+ text: t('limits.title', {
17
+ provider: String(opts.providerLabel ?? '').replace(/\s+/g, ' '),
18
+ }),
11
19
  });
12
20
  const windows = [
13
21
  ['10m', stats.last10m],
@@ -17,20 +25,20 @@ export function formatLimits(stats, opts, t = translate('en')) {
17
25
  for (const [label, n] of windows) {
18
26
  out.push({
19
27
  kind: 'window',
20
- text: t('limits.window', { label, n: String(n) }),
28
+ text: t('limits.window', { label, n: num(n) }),
21
29
  });
22
30
  }
23
31
  out.push({
24
32
  kind: 'rate',
25
33
  text: t('limits.rate', {
26
- n: String(stats.rateLimits),
27
- sec: String(Math.round(stats.avgBlockMs / 1000)),
34
+ n: num(stats.rateLimits),
35
+ sec: String(Math.round(Number(num(stats.avgBlockMs)) / 1000)),
28
36
  }),
29
37
  });
30
38
  out.push({
31
39
  kind: 'interval',
32
40
  text: t('limits.interval', {
33
- sec: String(Math.round(opts.intervalMs / 1000)),
41
+ sec: String(Math.round(Number(num(opts.intervalMs)) / 1000)),
34
42
  adaptive: opts.adaptive ? t('limits.adaptive_on') : '',
35
43
  }),
36
44
  });
@@ -260,7 +260,16 @@ export function stripThinkBlocks(text) {
260
260
  // "OK2"); the reasoning BODY was already removed with its subtree.
261
261
  // A single newline: with the blank-line form the `[\s\S]*?` below ate
262
262
  // the whole answer and the restored turn vanished.
263
- t = t.replace(/^\s*(?:thought|thinking|think)\s+for\s+[^\n]*\n?/i, '');
263
+ // The header is `Thought for <duration>`: the FIRST word is the past tense
264
+ // (`Thought`) or the bare `Think`, and the duration must look like a TIME
265
+ // (`1 second`, `12s`, `2 minutes`). Matching any `think for <anything>`
266
+ // dropped a plain answer that OPENS with the same words.
267
+ t = t.replace(/^\s*(?:thought|thinking|think)\s+for\s+\d+(?:\.\d+)?\s*(?:seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)(?![a-z])\b[^\n]*\n?/i, '');
268
+ // The DOM may hand the header WITHOUT its newline (`Thought for 1
269
+ // secondOK2`), so a header followed directly by the answer text is dropped
270
+ // too. The duration must be a real TIME word, never just `for <word>`: a
271
+ // plain answer opening with the same words keeps its line.
272
+ t = t.replace(/^\s*(?:thought|thinking|think)\s+for\s+\d+(?:\.\d+)?\s*(?:seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)(?=[A-Z\u0410-\u042f])/i, '');
264
273
  // The DOM may hand the header WITHOUT its newline ("Thought for 1
265
274
  // secondOK2"): drop the header up to the first non-space run only when a
266
275
  // line break or the end of the text follows it.
package/dist/scheduler.js CHANGED
@@ -256,6 +256,23 @@ export class Scheduler {
256
256
  due(now) {
257
257
  const fired = [];
258
258
  for (const job of this.jobs) {
259
+ // A NON-FINITE `nextAt` (a restored/hand-edited job, a NaN from a broken
260
+ // date) passed every comparison below: `NaN > now` is false, so the job
261
+ // was reported due on EVERY tick and the reschedule arithmetic kept it
262
+ // NaN forever — the busy loop the zero-interval guard exists to stop.
263
+ if (!Number.isFinite(job.nextAt)) {
264
+ if (job.kind === 'loop' && job.intervalMs && job.intervalMs > 0) {
265
+ job.nextAt = now + job.intervalMs;
266
+ }
267
+ else if (job.kind === 'cron' && job.cron) {
268
+ const nxt = nextCronTime(job.cron, now);
269
+ job.nextAt = nxt === null ? Number.POSITIVE_INFINITY : nxt;
270
+ }
271
+ else {
272
+ job.nextAt = Number.POSITIVE_INFINITY;
273
+ }
274
+ continue;
275
+ }
259
276
  if (job.nextAt > now)
260
277
  continue;
261
278
  fired.push({ ...job });
@@ -6,18 +6,29 @@ export function formatStatus(s, t = translate('en')) {
6
6
  const none = t('common.none');
7
7
  const onoff = (b) => (b ? t('common.on') : t('common.off'));
8
8
  const lines = [];
9
- lines.push(t('status.workdir', { v: s.workdir }));
10
- lines.push(t('status.review_mode', { v: s.reviewMode || no }));
9
+ // Every value printed here comes from the ENVIRONMENT (a working directory,
10
+ // a chat id) or from the config: a newline in one of them starts a status
11
+ // row of its own, so each value is flattened to one line — the same rule
12
+ // renderDoctor already applies to its cells.
13
+ const oneLine = (v) => String(v ?? '').replace(/\s+/g, ' ');
14
+ // A count must be a real number: `String(NaN)` used to print `NaN` in a row
15
+ // that reads like a measurement.
16
+ const count = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? String(n) : '0';
17
+ const val = (v, fallback) => v ? oneLine(v) : fallback;
18
+ lines.push(t('status.workdir', { v: oneLine(s.workdir) }));
19
+ lines.push(t('status.review_mode', {
20
+ v: s.reviewMode ? oneLine(s.reviewMode) : no,
21
+ }));
11
22
  if (s.reviewMode && s.origDir) {
12
- lines.push(t('status.orig_dir', { v: s.origDir }));
23
+ lines.push(t('status.orig_dir', { v: oneLine(s.origDir) }));
13
24
  }
14
- lines.push(t('status.chat', { v: s.chatId || none }));
25
+ lines.push(t('status.chat', { v: val(s.chatId, none) }));
15
26
  lines.push(t('status.fresh_next', { v: s.freshNext ? yes : no }));
16
27
  lines.push(t('status.prompt_next', { v: s.promptNext ? yes : no }));
17
- lines.push(t('status.last_chat', { v: s.lastChat || none }));
18
- lines.push(t('status.sessions', { v: s.sessionsDir }));
28
+ lines.push(t('status.last_chat', { v: val(s.lastChat, none) }));
29
+ lines.push(t('status.sessions', { v: oneLine(s.sessionsDir) }));
19
30
  lines.push(t('status.dev', { v: s.devMode ? t('common.on') : t('common.off') }));
20
- lines.push(t('status.max_iter', { v: s.maxIterations }));
31
+ lines.push(t('status.max_iter', { v: count(s.maxIterations) }));
21
32
  lines.push(t('status.headless', { v: s.headless ? yes : no }));
22
33
  lines.push(t('status.debug', { v: s.debug ? yes : no }));
23
34
  lines.push(t('status.toggles', {
@@ -26,27 +37,28 @@ export function formatStatus(s, t = translate('en')) {
26
37
  }));
27
38
  lines.push(t('status.undo', { v: onoff(s.undo) }));
28
39
  lines.push(t('status.checkpoint', { v: onoff(s.checkpoint) }));
29
- lines.push(t('status.transcript', { v: s.transcriptFile || t('common.off') }));
30
- // The GOAL is operator text (`/goal <free text>`) and a newline in it would
31
- // start a new terminal line, faking another status row (`FAKE: hacked`) —
32
- // one status entry must stay on one line.
33
- const oneLine = (v) => String(v ?? '').replace(/\s+/g, ' ');
40
+ lines.push(t('status.transcript', {
41
+ v: s.transcriptFile ? oneLine(s.transcriptFile) : t('common.off'),
42
+ }));
43
+ // The GOAL is operator text (`/goal <free text>`): one status entry must
44
+ // stay on one line.
34
45
  lines.push(t('status.goal', { v: s.goal ? oneLine(s.goal) : none }));
35
- lines.push(t('status.locale', { v: s.localeName }));
46
+ lines.push(t('status.locale', { v: oneLine(s.localeName) }));
36
47
  // A NEGATIVE tool count is nonsense (a broken MCP server status, a
37
- // hand-edited value): "MCP tools: -2" reads like a real measurement, the
38
- // same rule the token counter below already follows.
39
- // `none` ("(none)") would claim that the tools themselves are absent;
40
- // with no MCP pool configured the COUNT is simply unknown.
41
- const mcpCount = s.mcpToolCount === null || s.mcpToolCount < 0
48
+ // hand-edited value): "MCP tools: -2" reads like a real measurement.
49
+ const mcpCount = s.mcpToolCount === null ||
50
+ !Number.isFinite(s.mcpToolCount) ||
51
+ s.mcpToolCount < 0
42
52
  ? t('common.unknown')
43
- : s.mcpToolCount;
53
+ : String(s.mcpToolCount);
44
54
  lines.push(t('status.mcp', { v: mcpCount }));
45
55
  // A NEGATIVE count is nonsense (a broken provider counter): show "unknown"
46
56
  // rather than a number that reads like a real measurement.
47
- const tokens = s.tokenUsage === null || s.tokenUsage < 0 ? null : s.tokenUsage;
57
+ const tokens = s.tokenUsage === null || !Number.isFinite(s.tokenUsage) || s.tokenUsage < 0
58
+ ? null
59
+ : s.tokenUsage;
48
60
  lines.push(t('status.tokens', {
49
- v: tokens === null ? t('common.unknown') : tokens,
61
+ v: tokens === null ? t('common.unknown') : String(tokens),
50
62
  }));
51
63
  return lines;
52
64
  }
@@ -57,26 +69,31 @@ export function formatStatus(s, t = translate('en')) {
57
69
  */
58
70
  export function doctorProbeSummary(p) {
59
71
  const rows = [];
72
+ // A branch name, a missing capability id and a server/tool COUNT come from
73
+ // the repository and from a broken MCP probe: a newline started a row of its
74
+ // own and `NaN`/`-3` read like real measurements.
75
+ const oneLine = (v) => String(v ?? '').replace(/\s+/g, ' ');
76
+ const count = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : 0;
60
77
  rows.push({
61
78
  status: p.gitOk ? 'ok' : 'warn',
62
- label: p.gitOk ? 'git: ' + (p.gitBranch || 'detached') : 'git: absent',
79
+ label: p.gitOk
80
+ ? 'git: ' + oneLine(p.gitBranch || 'detached')
81
+ : 'git: absent',
63
82
  });
64
83
  rows.push({
65
84
  status: p.authSaved ? 'ok' : 'warn',
66
85
  label: p.authSaved ? 'auth: saved' : 'auth: not saved',
67
86
  });
87
+ const servers = count(p.mcpServers);
88
+ const tools = count(p.mcpTools);
68
89
  rows.push({
69
- status: p.mcpTools > 0 ? 'ok' : 'warn',
70
- label: 'mcp: ' +
71
- String(p.mcpServers) +
72
- ' servers / ' +
73
- String(p.mcpTools) +
74
- ' tools',
90
+ status: tools > 0 ? 'ok' : 'warn',
91
+ label: 'mcp: ' + String(servers) + ' servers / ' + String(tools) + ' tools',
75
92
  });
76
93
  if (p.uiMissingCritical && p.uiMissingCritical.length) {
77
94
  rows.push({
78
95
  status: 'warn',
79
- label: 'ui: missing ' + p.uiMissingCritical.join(', '),
96
+ label: 'ui: missing ' + oneLine(p.uiMissingCritical.join(', ')),
80
97
  });
81
98
  }
82
99
  return rows;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.96.1",
3
+ "version": "2.96.3",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen or GLM — 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": {