zames_pro 2.96.4 → 2.96.6

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,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.96.6] - 2026-11-06
11
+
12
+ ### Fixed
13
+
14
+ - **input:** drop a dangling ZWJ when a truncation cuts an emoji sequence
15
+ - **input:** count a ZWJ emoji sequence as one glyph in the layout
16
+ - **tui:** ZAMES_NO_BG=0 no longer disables the themed background
17
+ - **markdown:** an unknown code-fence language falls back to plain text
18
+ - **web:** decode an escaped HTML entity only once
19
+
20
+ ## [2.96.5] - 2026-11-06
21
+
22
+ ### Fixed
23
+
24
+ - **web:** tolerate whitespace around = in a DuckDuckGo result attribute
25
+ - **web:** recognise x-zip and x-msdownload as binary content
26
+ - **web:** drop the page head and keep <pre> code intact when converting HTML
27
+ - **web:** share one in-flight headless context between concurrent fetches
28
+ - **scheduler:** never scan cron forever on a non-finite fromMs
29
+
10
30
  ## [2.96.4] - 2026-11-06
11
31
 
12
32
  ### Fixed
@@ -167,6 +167,15 @@ export function visRows(s, cols) {
167
167
  // zero-width joiner/space/variation selectors count as 0.
168
168
  export function visLen(s) {
169
169
  s = String(s);
170
+ // An emoji SEQUENCE renders as ONE glyph but occupies several code
171
+ // points: ZWJ-joined, a skin-tone modifier, a keycap. Counting each
172
+ // point separately measured a family as 6 columns instead of 2, so the
173
+ // layout reserved extra rows and the cursor column drifted. The sequence
174
+ // is replaced by ONE placeholder cell before measuring.
175
+ s = s.replace(/(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\u200d|\ufe0f)+/gu,
176
+ // The placeholder is a WIDE glyph (2 columns), the width an emoji renders
177
+ // at: a single `X` would under-count it.
178
+ '😀');
170
179
  let n = 0;
171
180
  let i = 0;
172
181
  while (i < s.length) {
@@ -399,6 +408,11 @@ export function truncateToWidth(s, maxCols) {
399
408
  used += w;
400
409
  i += ch.length;
401
410
  }
411
+ // A cut inside a ZWJ emoji sequence leaves a DANGLING joiner: the terminal
412
+ // glues the following `…` to the emoji, so the marker becomes invisible and
413
+ // the visible line is wider than requested. Drop the dangling tail (the
414
+ // whole incomplete grapheme) before closing the style.
415
+ out = out.replace(/\u200d+$/, '');
402
416
  // The visible text was cut BEFORE any trailing RESET, so the ellipsis and
403
417
  // everything printed after it on the row would inherit the last colour.
404
418
  // Close any style the kept prefix opened (the text may still be plain, in
package/dist/markdown.js CHANGED
@@ -98,11 +98,31 @@ export function renderMarkdown(text) {
98
98
  // opt-out is NO_COLOR, handled above.
99
99
  color: true,
100
100
  theme: mdTheme(p),
101
- highlighter: (code, lang) => highlight(code, {
102
- language: lang || 'plaintext',
103
- ignoreIllegals: true,
104
- theme: hlTheme(p),
105
- }),
101
+ // An UNKNOWN language tag throws inside cli-highlight, and the throw
102
+ // escaped the whole render: ONE ```` ```kotlin-ish ```` fence turned the
103
+ // entire answer into raw markdown (no wrap, no theme, no code box). Fall
104
+ // back to plaintext for this block only.
105
+ highlighter: (code, lang) => {
106
+ try {
107
+ return highlight(code, {
108
+ language: lang || 'plaintext',
109
+ ignoreIllegals: true,
110
+ theme: hlTheme(p),
111
+ });
112
+ }
113
+ catch {
114
+ try {
115
+ return highlight(code, {
116
+ language: 'plaintext',
117
+ ignoreIllegals: true,
118
+ theme: hlTheme(p),
119
+ });
120
+ }
121
+ catch {
122
+ return String(code);
123
+ }
124
+ }
125
+ },
106
126
  codeBox: true,
107
127
  tableBorder: 'unicode',
108
128
  listIndent: 2,
package/dist/scheduler.js CHANGED
@@ -10,6 +10,9 @@ export function parseInterval(text) {
10
10
  const n = Number(m[1]);
11
11
  if (!Number.isFinite(n) || n <= 0)
12
12
  return null;
13
+ // `n * mult` can overflow to Infinity for a huge digit string; the range
14
+ // check below then compares Infinity > MAX_TIMER_MS and rejects it, but only
15
+ // AFTER the multiplication — keep the guard explicit for both.
13
16
  const unit = (m[2] || 's').toLowerCase();
14
17
  const mult = unit.startsWith('h')
15
18
  ? 3_600_000
@@ -163,9 +166,15 @@ export function nextCronTime(expr, fromMs) {
163
166
  // 02:45 job created after the first 02:45 fired the NEXT DAY. A fixed
164
167
  // 60-second step visits every real minute exactly once; the field checks
165
168
  // still use the LOCAL getters, so the wall-clock match is unchanged.
169
+ // A non-finite/huge `fromMs` (a clock past 8.64e15, a hand-edited job) made
170
+ // `limit` Infinity and the scan walked one minute at a time FOREVER.
171
+ if (!Number.isFinite(fromMs) || Math.abs(fromMs) > 8.64e15)
172
+ return null;
166
173
  const step = 60_000;
167
174
  let t = Math.floor(fromMs / step) * step + step;
168
175
  const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
176
+ if (!Number.isFinite(limit))
177
+ return null;
169
178
  while (t <= limit) {
170
179
  const d = new Date(t);
171
180
  if (c.minute.has(d.getMinutes()) &&
@@ -23,7 +23,13 @@ function hexToOsc(hex) {
23
23
  * testable without a terminal.
24
24
  */
25
25
  export function backgroundOsc(name) {
26
- if (process.env.NO_COLOR || process.env.ZAMES_NO_BG)
26
+ // NO_COLOR is "present means off" (no-color.org). ZAMES_NO_BG is our OWN
27
+ // switch: a shell profile writing `ZAMES_NO_BG=0` clearly means KEEP it, so
28
+ // only a truthy value opts out.
29
+ const noBg = String(process.env.ZAMES_NO_BG ?? '').toLowerCase();
30
+ if (process.env.NO_COLOR)
31
+ return '';
32
+ if (noBg && noBg !== '0' && noBg !== 'false' && noBg !== 'no')
27
33
  return '';
28
34
  const id = isThemeName(name) ? name : DEFAULT_THEME;
29
35
  return '\x1b]11;rgb:' + hexToOsc(ALL_PALETTES[id].bg) + '\x07';
package/dist/web.js CHANGED
@@ -37,6 +37,7 @@ export function htmlToText(html) {
37
37
  // document to the next.
38
38
  pendingHighSurrogate = null;
39
39
  // Remove blocks we don't need
40
+ const parkedPre = [];
40
41
  let s = html
41
42
  // A tag that never CLOSES (a truncated body, or HTML the regex cannot
42
43
  // pair) used to leak its raw JS/CSS into the text: also drop everything
@@ -47,6 +48,10 @@ export function htmlToText(html) {
47
48
  .replace(/<style\b[\s\S]*$/i, '')
48
49
  .replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
49
50
  .replace(/<noscript\b[\s\S]*$/i, '')
51
+ // The <head> (title, meta) is page CHROME, not content: left in place the
52
+ // title glued itself to the first paragraph (`My pageBody`).
53
+ .replace(/<head[\s\S]*?<\/head>/gi, '')
54
+ .replace(/<title[\s\S]*?<\/title>/gi, '')
50
55
  .replace(/<svg[\s\S]*?<\/svg>/gi, '')
51
56
  .replace(/<svg\b[\s\S]*$/i, '')
52
57
  .replace(/<!--[\s\S]*?-->/g, '');
@@ -60,15 +65,25 @@ export function htmlToText(html) {
60
65
  // `<td>42</td>` became `Name42` — two independent values fused into one, and
61
66
  // the model reported a number that never appeared on the page.
62
67
  s = s
68
+ // A `<pre>` block is CODE: a bare `<` inside it (`if (a < b)`) is text, not
69
+ // the start of a tag, so the generic strip below would eat the rest of the
70
+ // line. Park the block, then restore it after the tag removal.
71
+ .replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_m, code) => '\u0000PRE' + (parkedPre.push(code) - 1) + 'PRE\u0000')
63
72
  .replace(/<\/(h[1-6]|p|div|li|tr|section|article)>/gi, '\n')
64
73
  .replace(/<\/(td|th)>/gi, ' ')
65
74
  .replace(/<br\s*\/?>/gi, '\n')
66
75
  .replace(/<li[^>]*>/gi, '- ')
67
76
  .replace(/<[^>]+>/g, '');
68
- // HTML entities — the basic ones
77
+ // A parked `<pre>` block comes back AFTER the tag strip, with only its own
78
+ // tags removed and its text kept verbatim.
79
+ s = s.replace(/\u0000PRE(\d+)PRE\u0000/g, (_m, i) => (parkedPre[Number(i)] ?? '').replace(/<[^>]+>/g, ''));
80
+ // HTML entities — the basic ones. `&amp;` is decoded LAST, otherwise an
81
+ // escaped entity (`&amp;#38;` — the literal text `&#38;`) is decoded TWICE
82
+ // and the page's text is changed.
83
+ const AMP = String.fromCharCode(0) + 'amp' + String.fromCharCode(0);
84
+ s = s.replace(/&amp;/g, AMP);
69
85
  s = s
70
86
  .replace(/&nbsp;/g, ' ')
71
- .replace(/&amp;/g, '&')
72
87
  .replace(/&lt;/g, '<')
73
88
  .replace(/&gt;/g, '>')
74
89
  .replace(/&quot;/g, '"')
@@ -77,7 +92,9 @@ export function htmlToText(html) {
77
92
  .replace(/&ndash;/g, '–')
78
93
  .replace(/&hellip;/g, '…')
79
94
  .replace(/&#(\d+);/g, (_, n) => decodeEntityCode(Number(n)))
80
- .replace(/&#x([0-9a-f]+);/gi, (_, n) => decodeEntityCode(parseInt(n, 16)));
95
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => decodeEntityCode(parseInt(n, 16)))
96
+ .split(AMP)
97
+ .join('&');
81
98
  // Collapse blank lines
82
99
  s = s
83
100
  .split('\n')
@@ -406,7 +423,7 @@ export function isBinaryContentType(contentType) {
406
423
  if (/\+(json|xml)$/.test(ct))
407
424
  return false;
408
425
  if (/^(image|audio|video|font|model)\//.test(ct) ||
409
- /^application\/(pdf|zip|gzip|x-gzip|x-tar|octet-stream|msword|vnd\.|x-7z|x-rar|x-bzip|x-protobuf|wasm)/.test(ct)) {
426
+ /^application\/(pdf|zip|gzip|x-gzip|x-tar|octet-stream|msword|vnd\.|x-7z|x-rar|x-bzip|x-zip|x-msdownload|x-protobuf|wasm|epub\+zip)/.test(ct)) {
410
427
  return true;
411
428
  }
412
429
  return false;
@@ -503,13 +520,32 @@ async function getHeadless() {
503
520
  // tested without launching a real browser.
504
521
  export function createReusable(factory, dispose) {
505
522
  let cached = null;
523
+ // The FACTORY is async: checking `cached === null` and then awaiting it let
524
+ // two concurrent get() calls BOTH run the factory (two Chromium contexts,
525
+ // the first leaked). The in-flight promise is cached, so every caller waits
526
+ // for the SAME value.
527
+ let inflight = null;
506
528
  return {
507
529
  async get() {
508
- if (cached === null)
509
- cached = await factory();
510
- return cached;
530
+ if (cached !== null)
531
+ return cached;
532
+ if (!inflight) {
533
+ inflight = factory().then((v) => {
534
+ cached = v;
535
+ inflight = null;
536
+ return v;
537
+ }, (e) => {
538
+ inflight = null;
539
+ throw e;
540
+ });
541
+ }
542
+ return inflight;
511
543
  },
512
544
  async reset() {
545
+ if (inflight) {
546
+ // A failed factory must not leave a poisoned promise behind.
547
+ await inflight.catch(() => { });
548
+ }
513
549
  if (cached === null)
514
550
  return;
515
551
  const v = cached;
@@ -673,9 +709,11 @@ export function parseDuckDuckGoResults(body, limit) {
673
709
  // through a proxy does), so each quote style is accepted rather than only
674
710
  // `class="…"`. Rejecting them made a valid page parse to zero results and
675
711
  // WebSearch answer "the output format may have changed".
676
- const anchorRe = /<a[^>]*class=(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi;
712
+ // HTML allows whitespace around the `=` of an attribute (`class = "x"`);
713
+ // requiring it to touch both sides made such a page parse to ZERO results.
714
+ const anchorRe = /<a[^>]*class\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi;
677
715
  const hrefOf = (tag) => {
678
- const m = tag.match(/href=(?:"([^"]*)"|'([^']*)')/i);
716
+ const m = tag.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
679
717
  return m ? (m[1] ?? m[2] ?? '') : '';
680
718
  };
681
719
  let cur = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.96.4",
3
+ "version": "2.96.6",
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": {