zames_pro 2.73.2 → 2.73.4

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.73.4] - 2026-10-11
11
+
12
+ ### Changed
13
+
14
+ - docs(readme): restore mermaid-style diagram with Qwen, finish provider-neutral prose
15
+
16
+ ## [2.73.3] - 2026-10-11
17
+
18
+ ### Fixed
19
+
20
+ - harden SSRF guard, sandbox, git args, mcp lifecycle and editor
21
+
10
22
  ## [2.73.2] - 2026-10-11
11
23
 
12
24
  ### Changed
package/README.md CHANGED
@@ -28,8 +28,8 @@ through Playwright.
28
28
  In spirit it is similar to Claude Code / Codex CLI: it starts in the current
29
29
  directory, reads and edits files, runs commands, and commits to git.
30
30
 
31
- > No API key required — it drives the DeepSeek web chat like a regular user
32
- > through a real (headless) browser.
31
+ > No API key required — it drives a DeepSeek or Qwen web chat like a regular
32
+ > user through a real (headless) browser.
33
33
 
34
34
  <p align="center">
35
35
  <img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/demo.png" alt="zames session in the terminal" width="860">
@@ -44,10 +44,12 @@ zames # sign in once, then describe your task
44
44
  ```
45
45
 
46
46
  There is **no API key and no per-token bill**: zames signs in to **your own
47
- [chat.deepseek.com](https://chat.deepseek.com/) account** in a real (headless)
48
- Chromium and drives the web chat like a regular user. The first launch asks for
49
- your DeepSeek login once (the session is stored in `~/.zames/profile`); after
50
- that just run `zames` and type a task in plain language:
47
+ [chat.deepseek.com](https://chat.deepseek.com/) or
48
+ [chat.qwen.ai](https://chat.qwen.ai/) account** in a real (headless) Chromium
49
+ and drives the web chat like a regular user. The first launch asks for your
50
+ login once (the session is stored in `~/.zames/profile` for DeepSeek or
51
+ `~/.zames/qwen-profile` for Qwen); after that just run `zames` and type a task
52
+ in plain language:
51
53
 
52
54
  ```text
53
55
  ❯ refactor the config loader and add a test for the new default
@@ -122,8 +124,8 @@ Key pieces:
122
124
 
123
125
  ## Why zames?
124
126
 
125
- - **No API key, no per-token bill** — it uses your own DeepSeek chat account,
126
- not the paid API. Good for long, tool-heavy tasks.
127
+ - **No API key, no per-token bill** — it uses your own DeepSeek or Qwen chat
128
+ account, not the paid API. Good for long, tool-heavy tasks.
127
129
  - **Same workflow as Claude Code / Codex** — tools, `AGENTS.md`, skills, MCP
128
130
  and slash commands, so it feels familiar from day one.
129
131
  - **Runs unattended** — headless by default, resumable sessions, and `/loop`
@@ -143,11 +145,11 @@ Key pieces:
143
145
 
144
146
  ## Signing in
145
147
 
146
- The browser runs **headless by default**. When DeepSeek requires a sign-in,
147
- zames:
148
+ The browser runs **headless by default**. When the active backend requires a
149
+ sign-in, zames:
148
150
 
149
- 1. reuses the session stored in the persistent profile (`~/.zames/profile`)
150
- if it is still valid;
151
+ 1. reuses the session stored in the persistent profile (`~/.zames/profile` for
152
+ DeepSeek, `~/.zames/qwen-profile` for Qwen) if it is still valid;
151
153
  2. otherwise signs in automatically with the saved credentials
152
154
  (`browser.auth.username` / `browser.auth.password`);
153
155
  3. otherwise asks you for the login/password in the terminal (in a TTY) and,
@@ -165,7 +167,7 @@ The `--headless` flag (the default) and `headless: true` in the config keep
165
167
  the browser without a window; `--headed` / `headless: false` show it.
166
168
 
167
169
  Headless works out of the box: a headless Chrome normally advertises a
168
- `HeadlessChrome/...` User-Agent that DeepSeek's CDN blocks with a 403, so
170
+ `HeadlessChrome/...` User-Agent that the site's CDN blocks with a 403, so
169
171
  zames strips that marker before loading the page (keeping the real engine
170
172
  version). You do not need `--headed` just to log in.
171
173
 
@@ -233,7 +235,7 @@ The agent works inside the directory it was started in and cannot leave it (sand
233
235
  While the agent is working you can keep typing: press Enter to queue a message
234
236
  (it is sent right after the current task, in the same chat), or Esc / Ctrl+C to
235
237
  abort the current generation. This mirrors typing during generation on the
236
- DeepSeek website.
238
+ web chat.
237
239
 
238
240
  ### Input line
239
241
 
@@ -261,8 +263,8 @@ You can paste an image or a file into the input line (Ctrl+Shift+V / Shift+Inser
261
263
  or the terminal's own paste). zames saves it under `<project>/tmp` and shows a
262
264
  marker in the line - `[image#1]` for images, `[file#1]` for other files - then
263
265
  uploads the real file to the chat together with your message. These are the
264
- same formats the DeepSeek web chat accepts (PNG, JPEG, GIF, WEBP, BMP, SVG and
265
- the usual document types).
266
+ same formats the DeepSeek / Qwen web chat accepts (PNG, JPEG, GIF, WEBP, BMP,
267
+ SVG and the usual document types).
266
268
 
267
269
  Pasting an image works when the terminal sends it as a `data:` URL or as a
268
270
  base64 blob with a recognizable image signature. You can also paste a path to a
@@ -369,7 +371,7 @@ Codex CLI:
369
371
  (Write/Edit/MultiEdit/ApplyPatch/Bash, GitAdd/GitCommit/GitPush) are removed
370
372
  from the tool set, so the agent can investigate without touching the tree.
371
373
  Start in it with `--plan`.
372
- - /compact — ask DeepSeek to compress the current chat into a handover
374
+ - /compact — ask the active backend to compress the current chat into a handover
373
375
  summary, then open a NEW chat, resend the system prompt and post the summary
374
376
  as the carried-over context. Use it when the context gets long.
375
377
  - /goal [text|clear] — set a long-lived session goal. It is prepended to every
@@ -505,31 +507,34 @@ Agent data is stored in `~/.zames`: browser profile, logs, undo history, session
505
507
 
506
508
  ## FAQ
507
509
 
508
- **Is this an official DeepSeek product?**
509
- No. zames drives the public chat.deepseek.com web UI through a real browser,
510
- like a regular user. It is not affiliated with DeepSeek.
510
+ **Is this an official DeepSeek or Qwen product?**
511
+ No. zames drives the public `chat.deepseek.com` / `chat.qwen.ai` web UIs through
512
+ a real browser, like a regular user. It is not affiliated with DeepSeek or
513
+ Alibaba.
511
514
 
512
515
  **Do I need an API key?**
513
- No. You sign in with your own DeepSeek account once; the session is stored in
514
- `~/.zames/profile` and reused.
516
+ No. You sign in with your own account on the chat you use — DeepSeek (stored in
517
+ `~/.zames/profile`) or Qwen (stored in `~/.zames/qwen-profile`) — and the session
518
+ is reused. Qwen also works in guest mode for a few messages a day.
515
519
 
516
520
  **Which model does it use?**
517
- Whichever the DeepSeek web chat uses (DeepSeek-V3, or the reasoning model with
518
- "Deep thinking" on). zames never calls the API directly.
521
+ Whichever the active web chat uses. On DeepSeek that is DeepSeek-V3 (or the
522
+ reasoning model with "Deep thinking" on). On Qwen you can pick a model with
523
+ `/model` (e.g. `qwen3.7-plus`). zames never calls the API directly.
519
524
 
520
525
  **Does it work headless / on a server?**
521
526
  Yes — headless is the default, and `--output-format jsonl` makes it scriptable.
522
527
  `--headed` is only needed for a manual sign-in or selector debugging.
523
528
 
524
529
  **Is automating the web UI allowed?**
525
- That depends on DeepSeek's terms; automating a website may violate them. Use at
526
- your own risk and keep the send throttle (15s by default) so you do not hammer
527
- the rate limit.
530
+ That depends on the site's terms; automating a website may violate them. Use at
531
+ your own risk and keep the send throttle (15 s on DeepSeek by default) so you do
532
+ not hammer the rate limit.
528
533
 
529
534
  **How is it different from Claude Code / Codex?**
530
535
  Same shape (tools, `AGENTS.md`, skills, MCP, slash commands) but it runs on your
531
- DeepSeek account instead of an API, as a browser automation rather than a
532
- first-party API client.
536
+ DeepSeek or Qwen account instead of an API, as a browser automation rather than
537
+ a first-party API client.
533
538
 
534
539
  ## Links
535
540
 
@@ -554,7 +559,7 @@ MIT
554
559
  По духу он похож на Claude Code / Codex CLI: запускается в текущей
555
560
  директории, читает и правит файлы, выполняет команды и коммитит в git.
556
561
 
557
- > API-ключ не нужен — агент управляет веб-чатом DeepSeek как обычный
562
+ > API-ключ не нужен — агент управляет веб-чатом DeepSeek или Qwen как обычный
558
563
  > пользователь через настоящий (headless) браузер.
559
564
 
560
565
  <p align="center">
@@ -570,11 +575,12 @@ zames # один раз войти, дальше прост
570
575
  ```
571
576
 
572
577
  Здесь **нет API-ключа и нет оплаты за токены**: zames входит в **ваш
573
- собственный аккаунт [chat.deepseek.com](https://chat.deepseek.com/)** в
574
- настоящем (headless) Chromium и управляет веб-чатом как обычный пользователь.
575
- При первом запуске он один раз спросит логин DeepSeek (сессия сохраняется в
576
- `~/.zames/profile`); после этого достаточно запустить `zames` и написать
577
- задачу обычным языком:
578
+ собственный аккаунт [chat.deepseek.com](https://chat.deepseek.com/) или
579
+ [chat.qwen.ai](https://chat.qwen.ai/)** в настоящем (headless) Chromium и
580
+ управляет веб-чатом как обычный пользователь. При первом запуске он один раз
581
+ спросит логин (сессия сохраняется в `~/.zames/profile` для DeepSeek или
582
+ `~/.zames/qwen-profile` для Qwen); после этого достаточно запустить `zames` и
583
+ написать задачу обычным языком:
578
584
 
579
585
  ```text
580
586
  ❯ отрефактори загрузчик конфига и добавь тест на новый дефолт
@@ -654,7 +660,8 @@ zames не вызывает API модели. Он запускает headless C
654
660
  ## Почему zames?
655
661
 
656
662
  - **Нет API-ключа и счёта за токены** — используется ваш собственный аккаунт
657
- чата DeepSeek, а не платный API. Удобно для долгих задач с инструментами.
663
+ чата DeepSeek или Qwen, а не платный API. Удобно для долгих задач с
664
+ инструментами.
658
665
  - **Тот же рабочий процесс, что в Claude Code / Codex** — инструменты,
659
666
  `AGENTS.md`, навыки, MCP и слэш-команды, так что всё знакомо с первого дня.
660
667
  - **Работает без присмотра** — headless по умолчанию, возобновляемые сессии,
@@ -674,11 +681,11 @@ zames не вызывает API модели. Он запускает headless C
674
681
 
675
682
  ## Вход в аккаунт
676
683
 
677
- Браузер по умолчанию работает **headless**. Когда DeepSeek требует входа,
678
- zames:
684
+ Браузер по умолчанию работает **headless**. Когда активный бэкенд требует
685
+ входа, zames:
679
686
 
680
- 1. переиспользует сессию из постоянного профиля (`~/.zames/profile`), если она
681
- ещё валидна;
687
+ 1. переиспользует сессию из постоянного профиля (`~/.zames/profile` для
688
+ DeepSeek, `~/.zames/qwen-profile` для Qwen), если она ещё валидна;
682
689
  2. иначе входит автоматически по сохранённым учётным данным
683
690
  (`browser.auth.username` / `browser.auth.password`);
684
691
  3. иначе спрашивает логин/пароль в терминале (в TTY) и после успешного входа
@@ -696,7 +703,7 @@ zames --headed
696
703
  браузер без окна; `--headed` / `headless: false` показывают его.
697
704
 
698
705
  Headless работает из коробки: headless Chrome обычно представляется как
699
- `HeadlessChrome/...`, и CDN DeepSeek блокирует такой User-Agent ответом 403,
706
+ `HeadlessChrome/...`, и CDN сайта блокирует такой User-Agent ответом 403,
700
707
  поэтому zames срезает этот маркер перед загрузкой страницы (сохраняя реальную
701
708
  версию движка). `--headed` нужен не только ради входа.
702
709
 
@@ -762,7 +769,7 @@ zames
762
769
  Пока агент работает, можно продолжать печатать: Enter ставит сообщение в
763
770
  очередь (оно уйдёт сразу после текущей задачи, в тот же чат), а Esc / Ctrl+C
764
771
  прерывают текущую генерацию. Это повторяет набор текста во время генерации на
765
- сайте DeepSeek.
772
+ сайте чата.
766
773
 
767
774
  ### Строка ввода
768
775
 
@@ -794,8 +801,8 @@ zames
794
801
  или собственной вставкой терминала). zames сохраняет его в `<project>/tmp` и
795
802
  показывает маркер в строке — `[image#1]` для картинок, `[file#1]` для прочих
796
803
  файлов — затем загружает реальный файл в чат вместе с вашим сообщением. Это те
797
- же форматы, что принимает веб-чат DeepSeek (PNG, JPEG, GIF, WEBP, BMP, SVG и
798
- обычные типы документов).
804
+ же форматы, что принимает веб-чат DeepSeek / Qwen (PNG, JPEG, GIF, WEBP, BMP,
805
+ SVG и обычные типы документов).
799
806
 
800
807
  Вставка картинки работает, когда терминал передаёт её как `data:` URL или как
801
808
  base64-блок с узнаваемой сигнатурой изображения. Можно также вставить путь к
@@ -904,8 +911,8 @@ zames --task "..." --output-format jsonl \
904
911
  инструменты (Write/Edit/MultiEdit/ApplyPatch/Bash, GitAdd/GitCommit/GitPush)
905
912
  убраны из набора, чтобы агент изучал код, не трогая дерево. Стартовать в нём
906
913
  можно с `--plan`.
907
- - /compact — попросить DeepSeek сжать текущий чат в передаточное резюме, затем
908
- открыть НОВЫЙ чат, заново отправить системный промпт и положить резюме как
914
+ - /compact — попросить активный бэкенд сжать текущий чат в передаточное резюме,
915
+ затем открыть НОВЫЙ чат, заново отправить системный промпт и положить резюме как
909
916
  перенесённый контекст. Используйте, когда контекст разрастается.
910
917
  - /goal [text|clear] — задать долгоживущую цель сессии. Она добавляется в
911
918
  начало каждого сообщения задачи, поэтому модель держит общую картину через
@@ -1044,31 +1051,35 @@ enum переключаются на месте; числа и строки от
1044
1051
 
1045
1052
  ## FAQ
1046
1053
 
1047
- **Это официальный продукт DeepSeek?**
1048
- Нет. zames управляет публичным веб-интерфейсом chat.deepseek.com через
1049
- настоящий браузер, как обычный пользователь. Он не связан с DeepSeek.
1054
+ **Это официальный продукт DeepSeek или Qwen?**
1055
+ Нет. zames управляет публичными веб-интерфейсами `chat.deepseek.com` /
1056
+ `chat.qwen.ai` через настоящий браузер, как обычный пользователь. Он не связан
1057
+ ни с DeepSeek, ни с Alibaba.
1050
1058
 
1051
1059
  **Нужен ли API-ключ?**
1052
- Нет. Вы один раз входите в свой аккаунт DeepSeek; сессия хранится в
1053
- `~/.zames/profile` и переиспользуется.
1060
+ Нет. Вы один раз входите в свой аккаунт на нужном чате — DeepSeek (хранится в
1061
+ `~/.zames/profile`) или Qwen (хранится в `~/.zames/qwen-profile`) — и сессия
1062
+ переиспользуется. Qwen также работает в гостевом режиме на несколько сообщений.
1054
1063
 
1055
1064
  **Какую модель он использует?**
1056
- Ту, которую использует веб-чат DeepSeek (DeepSeek-V3 или reasoning-модель при
1057
- включённом «Deep thinking»). zames никогда не вызывает API напрямую.
1065
+ Ту, которую использует активный веб-чат. В DeepSeek это DeepSeek-V3 (или
1066
+ reasoning-модель при включённом «Deep thinking»). В Qwen модель можно выбрать
1067
+ командой `/model` (например, `qwen3.7-plus`). zames никогда не вызывает API
1068
+ напрямую.
1058
1069
 
1059
1070
  **Работает ли headless / на сервере?**
1060
1071
  Да — headless по умолчанию, а `--output-format jsonl` делает его скриптуемым.
1061
1072
  `--headed` нужен только для ручного входа или отладки селекторов.
1062
1073
 
1063
1074
  **Разрешена ли автоматизация веб-интерфейса?**
1064
- Это зависит от условий DeepSeek; автоматизация сайта может их нарушать.
1065
- Используйте на свой риск и держите троттлинг отправки (15 с по умолчанию),
1066
- чтобы не бить по рейт-лимиту.
1075
+ Это зависит от условий сайта; автоматизация сайта может их нарушать.
1076
+ Используйте на свой риск и держите троттлинг отправки (15 с в DeepSeek по
1077
+ умолчанию), чтобы не бить по рейт-лимиту.
1067
1078
 
1068
1079
  **Чем отличается от Claude Code / Codex?**
1069
1080
  Та же форма (инструменты, `AGENTS.md`, навыки, MCP, слэш-команды), но работает
1070
- на вашем аккаунте DeepSeek вместо API, как автоматизация браузера, а не
1071
- first-party API-клиент.
1081
+ на вашем аккаунте DeepSeek или Qwen вместо API, как автоматизация браузера, а
1082
+ не first-party API-клиент.
1072
1083
 
1073
1084
  ## Ссылки
1074
1085
 
@@ -197,18 +197,22 @@ export function looksLikeFileName(name) {
197
197
  export async function saveToTemp(tmpDir, name, data) {
198
198
  await fs.mkdir(tmpDir, { recursive: true });
199
199
  const base = sanitize(name);
200
- let target = path.join(tmpDir, base);
201
- if (await fs.stat(target).catch(() => null)) {
202
- const ext = path.extname(base);
203
- const stem = base.slice(0, base.length - ext.length);
204
- let i = 1;
205
- while (await fs.stat(target).catch(() => null)) {
206
- target = path.join(tmpDir, stem + '-' + i + ext);
207
- i++;
200
+ const ext = path.extname(base);
201
+ const stem = base.slice(0, base.length - ext.length);
202
+ // O_EXCL ('wx'): pick the first free name and CREATE it atomically. A
203
+ // stat-then-write raced with another attach and silently overwrote a file.
204
+ for (let i = 0; i < 10_000; i++) {
205
+ const target = path.join(tmpDir, i === 0 ? base : stem + '-' + i + ext);
206
+ try {
207
+ await fs.writeFile(target, data, { flag: 'wx' });
208
+ return target;
209
+ }
210
+ catch (e) {
211
+ if (e.code !== 'EEXIST')
212
+ throw e;
208
213
  }
209
214
  }
210
- await fs.writeFile(target, data);
211
- return target;
215
+ throw new Error('could not find a free temp name for ' + base);
212
216
  }
213
217
  // True if any OS clipboard tool is available on this machine.
214
218
  export function hasClipboardTool() {
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import { execFile } from 'child_process';
5
+ import { randomBytes } from 'crypto';
5
6
  import { writeJsonAtomic } from './fsutil.js';
6
7
  // Checkpoints / rewind (BACKLOG B3).
7
8
  //
@@ -120,7 +121,11 @@ export class CheckpointStore {
120
121
  return null;
121
122
  await this._ensure();
122
123
  const stamp = Date.now();
123
- const file = path.join(this.dir, `${stamp}.tgz`);
124
+ // Two checkpoints in the SAME millisecond would share `${stamp}.tgz` and
125
+ // the second would overwrite the first (losing a snapshot). Add a random
126
+ // suffix so an archive name is unique.
127
+ const uniq = randomBytes(3).toString('hex');
128
+ const file = path.join(this.dir, `${stamp}-${uniq}.tgz`);
124
129
  const res = await runTar(tarCreateArgs(file, workdir), workdir);
125
130
  if (res.code !== 0) {
126
131
  await fs.rm(file, { force: true }).catch(() => { });
@@ -187,7 +192,19 @@ export class CheckpointStore {
187
192
  .rm(path.join(record.workdir, e), { recursive: true, force: true })
188
193
  .catch(() => { });
189
194
  }
190
- await copyTree(tmp, record.workdir);
195
+ // A copy failure here must NOT surface as an uncaught throw that leaves
196
+ // the (already wiped) tree half-restored: report it as a failure so the
197
+ // caller can tell the operator to /rewind from the pre-rewind backup.
198
+ try {
199
+ await copyTree(tmp, record.workdir);
200
+ }
201
+ catch (e) {
202
+ return {
203
+ ok: false,
204
+ reason: 'copy_failed',
205
+ detail: e.message,
206
+ };
207
+ }
191
208
  return { ok: true, record, backup };
192
209
  }
193
210
  finally {
package/dist/config.js CHANGED
@@ -119,6 +119,12 @@ export function providerAskDeadlineMs(cfg) {
119
119
  }
120
120
  function deepMerge(target, source) {
121
121
  for (const key of Object.keys(source)) {
122
+ // A `.zamesrc.json` (possibly shared/copied from elsewhere) could carry a
123
+ // `__proto__` / `constructor` / `prototype` key; assigning those to a
124
+ // plain object mutates Object.prototype (prototype pollution). Skip them.
125
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
126
+ continue;
127
+ }
122
128
  if (source[key] &&
123
129
  typeof source[key] === 'object' &&
124
130
  !Array.isArray(source[key])) {
package/dist/context.js CHANGED
@@ -211,7 +211,17 @@ export function skillBody(raw) {
211
211
  const end = norm.indexOf(NL + '---', 3);
212
212
  if (end === -1)
213
213
  return norm.trim();
214
- return norm.slice(norm.indexOf(NL, end + 1) + 1).trim();
214
+ // The closing `---` may be the LAST line with no trailing newline; then
215
+ // indexOf(NL, …) is -1 and slice(0) returned the WHOLE file (frontmatter
216
+ // included). Guard the -1 -> the body is everything after the closing ---.
217
+ const after = norm.indexOf(NL, end + 1);
218
+ if (after === -1) {
219
+ // No newline after the closing delimiter: the body is empty (or just the
220
+ // rest of that final line, which IS the `---`).
221
+ const rest = norm.slice(end + 1);
222
+ return rest.replace(/^-*$/, '').trim();
223
+ }
224
+ return norm.slice(after + 1).trim();
215
225
  }
216
226
  async function findSkillFiles(root, depth = 3) {
217
227
  const out = [];
@@ -500,11 +510,17 @@ async function loadScopedAgents(workdir, touchPaths) {
500
510
  const out = [];
501
511
  const seen = new Set();
502
512
  for (const dir of dirs) {
513
+ // AGENTS.md is head-clipped; MEMORY.md is append-only so it uses the
514
+ // memory budget and keeps its TAIL (same rule as the main memory chain) —
515
+ // otherwise the newest notes in a large nested MEMORY.md were lost.
503
516
  for (const names of [AGENT_NAMES, MEMORY_NAMES]) {
517
+ const isMemory = names === MEMORY_NAMES;
504
518
  const p = await findNamedFile(dir, names);
505
519
  if (!p || seen.has(p))
506
520
  continue;
507
- const f = await readIfFile(p, MAX_FILE_BASE);
521
+ const f = isMemory
522
+ ? await readIfFile(p, MAX_MEMORY_FILE, true)
523
+ : await readIfFile(p, MAX_FILE_BASE);
508
524
  if (!f)
509
525
  continue;
510
526
  seen.add(p);
package/dist/gitTools.js CHANGED
@@ -150,7 +150,11 @@ export function createGitTools(workdir) {
150
150
  const err = await ensureRepo();
151
151
  if (err)
152
152
  return err;
153
- return runGitArgs(['log', '--oneline', '--decorate', '-n', String(Number(count) || 10)], workdir, 10_000);
153
+ // Guard the count: `Number(count)||10` let -3 and Infinity through as
154
+ // literal `-n -3` / `-n Infinity`. Keep a positive integer only.
155
+ const n = Math.trunc(Number(count));
156
+ const lim = Number.isFinite(n) && n > 0 ? n : 10;
157
+ return runGitArgs(['log', '--oneline', '--decorate', '-n', String(lim)], workdir, 10_000);
154
158
  },
155
159
  },
156
160
  {
@@ -238,14 +242,17 @@ export function createGitTools(workdir) {
238
242
  const fs = await import('fs/promises');
239
243
  const path = await import('path');
240
244
  const os = await import('os');
241
- const tmp = path.join(os.tmpdir(), `dsa-commit-${Date.now()}.txt`);
242
- await fs.writeFile(tmp, msg, 'utf-8');
245
+ // O_EXCL ('wx') so a pre-created symlink at this predictable path
246
+ // cannot redirect the write to another file (TOCTOU on a shared /tmp).
247
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zames-commit-'));
248
+ const tmp = path.join(tmpDir, 'msg.txt');
249
+ await fs.writeFile(tmp, msg, { encoding: 'utf-8', flag: 'wx' });
243
250
  try {
244
251
  const result = await runGitArgs(['commit', '-F', tmp], workdir, 30_000);
245
252
  return `add: ${addResult}\ncommit: ${result}`;
246
253
  }
247
254
  finally {
248
- await fs.unlink(tmp).catch(() => { });
255
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
249
256
  }
250
257
  },
251
258
  },
@@ -269,6 +276,15 @@ export function createGitTools(workdir) {
269
276
  if (!/^[A-Za-z0-9._/-]+$/.test(b) || b.startsWith('-')) {
270
277
  return `Error: invalid branch name: ${b}`;
271
278
  }
279
+ // The regex above allows `..` and `@{`, which git parses as a REFSPEC
280
+ // RANGE (`git push origin a..b`), not a branch. Ask git itself so the
281
+ // name is valid by construction.
282
+ const refCheck = await runGitArgs(['check-ref-format', '--branch', b], workdir, 5000);
283
+ // On success git echoes the branch name; on failure it prints an
284
+ // `Exit code: <n>` / `fatal:` block instead.
285
+ if (refCheck.trim() !== b) {
286
+ return `Error: invalid branch name: ${b}`;
287
+ }
272
288
  const args = ['push'];
273
289
  if (setUpstream)
274
290
  args.push('-u');
package/dist/index.js CHANGED
@@ -85,7 +85,13 @@ const headless = hasFlag('--headed')
85
85
  : hasFlag('--headless') || config.headless;
86
86
  const debug = hasFlag('--debug') || config.debug;
87
87
  const maxIterArg = getArg('--max-iter', null);
88
- const maxIter = maxIterArg !== null ? Number(maxIterArg) : config.maxIterations;
88
+ // A non-numeric `--max-iter abc` yields NaN, which the loop treats as
89
+ // "unlimited" (NaN > 0 is false). Fall back to the configured value instead
90
+ // of silently disabling the cap.
91
+ const maxIterParsed = maxIterArg !== null ? Number(maxIterArg) : NaN;
92
+ const maxIter = Number.isFinite(maxIterParsed)
93
+ ? maxIterParsed
94
+ : config.maxIterations;
89
95
  const positional = getPositional();
90
96
  const task = getArg('--task', positional.join(' ').trim() || null);
91
97
  const chatIdArg = getArg('--chat', null);
package/dist/input.js CHANGED
@@ -892,28 +892,17 @@ export class LineEditor {
892
892
  }
893
893
  const cols = process.stdout.columns || 80;
894
894
  const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
895
- const sugg = this._suggestions();
896
- const shown = sugg.slice(0, 8);
897
- this._suggestCount = shown.length;
898
- const linesBelow = shown.length
899
- ? shown.length + (sugg.length > shown.length ? 1 : 0)
900
- : 0;
895
+ // Reuse the SAME windowed+highlighted suggestion lines as _writeBlock.
896
+ // Before, this used a hardcoded `sugg.slice(0, 8)`, so navigating past
897
+ // page 1 (Ctrl+N/P) left the repaint showing the first page without the
898
+ // highlight, and linesBelow drifted from the real row count.
899
+ const sug = this._suggestionLines(this._suggestOffset);
900
+ const linesBelow = sug.rows;
901
901
  // Move up to the INPUT top using the OLD cursor row (on screen right now).
902
902
  this._eraseInputOnly(this.cursorRowFromTop - this._statusTop);
903
903
  let out = lay.rows.map((r) => r.prefix + r.text).join(NL);
904
- if (shown.length) {
905
- const maxName = Math.max(...shown.map((c) => c.name.length));
906
- const lines = shown.map((c) => {
907
- const name = theme.prompt(c.name.padEnd(maxName));
908
- const desc = theme.dim(' ' + c.description);
909
- return ' ' + name + desc;
910
- });
911
- out += NL + lines.join(NL);
912
- const hidden = sugg.length - shown.length;
913
- if (hidden > 0)
914
- out +=
915
- NL +
916
- theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
904
+ if (sug.lines.length) {
905
+ out += NL + sug.lines.join(NL);
917
906
  }
918
907
  // A buffer change can UNWRAP the input (backspace on a wrapped line), so
919
908
  // the block can get shorter here too — push it back to the bottom BEFORE
package/dist/mcp.js CHANGED
@@ -44,9 +44,9 @@ export function hardenPlaywrightArgs(command, args) {
44
44
  const hay = (String(command || '') + ' ' + args.join(' ')).toLowerCase();
45
45
  if (!hay.includes('playwright') || !hay.includes('mcp'))
46
46
  return args;
47
- if (args.some((a) => a === '--isolated' ||
47
+ if (args.some((a) => a.startsWith('--isolated') ||
48
48
  a.startsWith('--user-data-dir') ||
49
- a === '--extension' ||
49
+ a.startsWith('--extension') ||
50
50
  a.startsWith('--cdp-endpoint'))) {
51
51
  return args;
52
52
  }
@@ -292,10 +292,15 @@ export function dedupeToolNames(tools) {
292
292
  }
293
293
  // Connect to ONE server and return a ready connection (or an error).
294
294
  async function connectServer(name, cfg, debug) {
295
+ // Declared OUTSIDE the try so the catch can close a half-open server: a
296
+ // stdio transport SPAWNS a child process on connect, and if connect() or
297
+ // listTools() throws we would otherwise leak an orphan process on every run.
298
+ let client = null;
299
+ let transport = null;
295
300
  try {
296
301
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
297
- const client = new Client({ name: 'zames', version: packageVersion() }, { capabilities: {} });
298
- let transport;
302
+ const c = new Client({ name: 'zames', version: packageVersion() }, { capabilities: {} });
303
+ client = c;
299
304
  if (cfg.url) {
300
305
  if (cfg.transport === 'sse') {
301
306
  const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
@@ -320,8 +325,8 @@ async function connectServer(name, cfg, debug) {
320
325
  stderr: 'pipe',
321
326
  });
322
327
  }
323
- await client.connect(transport);
324
- const listed = await client.listTools();
328
+ await c.connect(transport);
329
+ const listed = await c.listTools();
325
330
  const tools = (listed.tools || []).map((t) => {
326
331
  const tool = t;
327
332
  return {
@@ -331,11 +336,22 @@ async function connectServer(name, cfg, debug) {
331
336
  };
332
337
  });
333
338
  debug?.(`MCP: connected to ${name} (${tools.length} tools)`);
334
- return { name, client, tools };
339
+ return { name, client: c, tools };
335
340
  }
336
341
  catch (e) {
337
342
  const msg = e.message || String(e);
338
343
  debug?.(`MCP: failed to connect to ${name}: ${msg}`);
344
+ // Close a half-open connection so a spawned stdio child is not orphaned.
345
+ try {
346
+ if (client)
347
+ await client.close().catch(() => { });
348
+ }
349
+ catch { }
350
+ try {
351
+ if (transport?.close)
352
+ await Promise.resolve(transport.close()).catch(() => { });
353
+ }
354
+ catch { }
339
355
  return { error: msg };
340
356
  }
341
357
  }
package/dist/sessions.js CHANGED
@@ -98,11 +98,16 @@ function writeIndex({ id, workdir }) {
98
98
  if (!index.byWorkdir || typeof index.byWorkdir !== 'object') {
99
99
  index.byWorkdir = {};
100
100
  }
101
+ // An EMPTY workdir must not become a byWorkdir key: it would make every
102
+ // project fall back to `index.last` and restore a chat from ANOTHER
103
+ // project on the next launch. Only track a real directory.
101
104
  const key = workdir || '';
102
- index.byWorkdir[key] = id;
103
- index.last = id;
104
- index.updatedAt = new Date().toISOString();
105
- writeJsonAtomic(INDEX_FILE, index);
105
+ if (key) {
106
+ index.byWorkdir[key] = id;
107
+ index.last = id;
108
+ index.updatedAt = new Date().toISOString();
109
+ writeJsonAtomic(INDEX_FILE, index);
110
+ }
106
111
  }
107
112
  catch { }
108
113
  }
package/dist/shell.js CHANGED
@@ -23,6 +23,15 @@ export function assertCommandInsideRoot(root, command) {
23
23
  const raw = m[1].trim().replace(/^["']|["']$/g, '');
24
24
  if (!raw || raw === '-')
25
25
  continue;
26
+ // A `cd` target we cannot RESOLVE statically is rejected: the shell would
27
+ // expand it AFTER this scan, so `d=/; cd $d` would otherwise slip past as
28
+ // root/$d. `$HOME`/`${HOME}` is the one variable we CAN expand (below), so
29
+ // it is allowed; any OTHER `$var`, a backtick substitution or `~user` is
30
+ // refused outright.
31
+ const withoutHome = raw.replace(/\$\{?HOME\}?/g, '');
32
+ if (/[$`]/.test(withoutHome) || /^~[^/\\]/.test(raw)) {
33
+ throw new Error('Sandbox: cannot verify cd target ' + raw + ' (variable/substitution)');
34
+ }
26
35
  // sh expands ~ and $HOME AFTER this check, so expand them here as well —
27
36
  // otherwise `cd ~` passes the guard and then leaves the root.
28
37
  const expanded = raw
@@ -64,10 +64,10 @@ export class Transcript {
64
64
  if (!stream)
65
65
  return Promise.resolve();
66
66
  return new Promise((resolve) => {
67
- stream.end(() => resolve());
68
- // A write error must not hang the exit; the error listener is installed
69
- // in the constructor.
67
+ // Attach the error handler BEFORE end(): an error emitted synchronously
68
+ // by end() would otherwise reach no listener on this tick.
70
69
  stream.on('error', () => resolve());
70
+ stream.end(() => resolve());
71
71
  });
72
72
  }
73
73
  }
package/dist/undo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { randomBytes } from 'crypto';
4
5
  import { writeJsonAtomic } from './fsutil.js';
5
6
  const UNDO_DIR = path.join(os.homedir(), '.zames', 'undo');
6
7
  const INDEX = path.join(UNDO_DIR, 'index.json');
@@ -44,7 +45,12 @@ export class UndoStore {
44
45
  const hash = Buffer.from(filePath).toString('hex').slice(0, 16);
45
46
  let backupFile = null;
46
47
  if (existed) {
47
- backupFile = path.join(UNDO_DIR, `${stamp}-${hash}.bak`);
48
+ // The stamp is only ms-precision and the hash is the first 8 bytes of
49
+ // the path, so two SIBLING files edited in the same millisecond would
50
+ // collide and the second write would clobber the first (losing the
51
+ // backup). Add a random suffix so a backup name is unique.
52
+ const uniq = randomBytes(4).toString('hex');
53
+ backupFile = path.join(UNDO_DIR, `${stamp}-${hash}-${uniq}.bak`);
48
54
  await fs.writeFile(backupFile, content);
49
55
  }
50
56
  const history = await this._readIndex();
package/dist/web.js CHANGED
@@ -71,12 +71,34 @@ export function isPrivateIp(ip) {
71
71
  const low = ip.toLowerCase();
72
72
  if (low === '::1' || low === '::')
73
73
  return true;
74
- if (low.startsWith('fe80') || low.startsWith('fc') || low.startsWith('fd')) {
74
+ // IPv6 link-local is fe80::/10, i.e. fe80..febf (the old `startsWith('fe80')`
75
+ // missed fe90::, fea0:: and febf::). ULA is fc00::/7 (fc..fd). fec0::/10
76
+ // (deprecated site-local) is also treated as non-public.
77
+ if (/^fe[89abcde]/.test(low) ||
78
+ low.startsWith('fc') ||
79
+ low.startsWith('fd')) {
75
80
  return true;
76
81
  }
82
+ // IPv4-mapped in DOTTED form (::ffff:127.0.0.1).
77
83
  const mapped = low.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
78
84
  if (mapped)
79
85
  return isPrivateIp(mapped[1]);
86
+ // IPv4-mapped in HEX form (::ffff:7f00:1 == 127.0.0.1) and the NAT64
87
+ // well-known prefix 64:ff9b::/96 (which can encode 127.0.0.1 too).
88
+ const mappedHex = low.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
89
+ if (mappedHex) {
90
+ const hi = parseInt(mappedHex[1], 16);
91
+ const lo = parseInt(mappedHex[2], 16);
92
+ return isPrivateIp(((hi >> 8) & 0xff) +
93
+ '.' +
94
+ (hi & 0xff) +
95
+ '.' +
96
+ ((lo >> 8) & 0xff) +
97
+ '.' +
98
+ (lo & 0xff));
99
+ }
100
+ if (low.startsWith('64:ff9b:'))
101
+ return true;
80
102
  return false;
81
103
  }
82
104
  export function isPrivateHostname(host) {
@@ -87,6 +109,13 @@ export function isPrivateHostname(host) {
87
109
  return true;
88
110
  if (h === 'metadata.google.internal')
89
111
  return true;
112
+ // Internal/mDNS/service names that never belong to the public internet.
113
+ if (h.endsWith('.internal') || h === 'internal')
114
+ return true;
115
+ if (h.endsWith('.local') || h === 'local')
116
+ return true;
117
+ if (h.endsWith('.home.arpa'))
118
+ return true;
90
119
  return false;
91
120
  }
92
121
  export async function assertPublicUrl(rawUrl) {
@@ -191,6 +220,10 @@ export async function httpFetch(url, { timeout = DEFAULT_TIMEOUT, headers = {},
191
220
  const next = new URL(loc, current).toString();
192
221
  // Validate the TARGET before requesting it.
193
222
  await assertPublicUrl(next);
223
+ // Drain/close the redirect body: otherwise each hop leaks a socket
224
+ // (up to MAX_REDIRECTS per fetch), which exhausts the pool on a long
225
+ // series of fetches.
226
+ await res.body?.cancel().catch(() => { });
194
227
  current = next;
195
228
  continue;
196
229
  }
@@ -310,7 +343,7 @@ export function createWebTools() {
310
343
  if (!/^https?:\/\//i.test(String(url))) {
311
344
  return `Error: URL must start with http:// or https://`;
312
345
  }
313
- const limit = Math.min(Number(maxChars) || MAX_TEXT, 60_000);
346
+ const limit = Math.max(1, Math.min(Number(maxChars) || MAX_TEXT, 60_000));
314
347
  try {
315
348
  await assertPublicUrl(String(url));
316
349
  if (render) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.73.2",
3
+ "version": "2.73.4",
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": {
@@ -1,13 +1,13 @@
1
- // Render docs/how-it-works.png — the architecture diagram used in README.md.
2
- //
3
- // Why a generator: the README must show BOTH chat backends (DeepSeek and
4
- // Qwen), not just DeepSeek. A hand-drawn PNG drifts; this renders a fixed
5
- // HTML page with headless Chromium so the diagram is reproducible and easy to
6
- // update when a provider is added.
1
+ // Render docs/how-it-works.png — the "How it works" flowchart used in
2
+ // README.md. Reproduces the ORIGINAL mermaid `flowchart LR` look (rounded
3
+ // lavender nodes, gray arrows, white background, a simple left-to-right
4
+ // chain) so the diagram stays visually the same as before. The ONLY change:
5
+ // the model node now shows BOTH supported backends (chat.deepseek.com /
6
+ // chat.qwen.ai) instead of only chat.deepseek.com.
7
7
  //
8
8
  // Run: npm run render-how (tsx scripts/render-how-it-works.mts)
9
9
  //
10
- // Isolation: chromium.launch() uses a THROWAWAY in-memory context, never
10
+ // Isolation: chromium.launch() uses a THROWAWAY context, never
11
11
  // ~/.zames/profile (see AGENTS.md — never touch the live agent's browser).
12
12
 
13
13
  import { chromium } from 'playwright'
@@ -17,103 +17,165 @@ import { fileURLToPath } from 'node:url'
17
17
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
18
18
  const out = path.join(root, 'docs', 'how-it-works.png')
19
19
 
20
- const C = {
21
- bg: '#23272e',
22
- panel: '#2b303a',
23
- border: '#3b4250',
24
- text: '#cfc9b0',
25
- dim: '#8a9bb5',
26
- user: '#b4b8d0',
27
- accent: '#7fc4f0',
28
- ds: '#6f8ed0',
29
- qwen: '#6fd0b0',
30
- tool: '#c6a97e',
20
+ // Mermaid default-theme palette (matches the previous diagram).
21
+ const FILL = '#ECECFF'
22
+ const STROKE = '#9370DB'
23
+ const TEXT = '#333333'
24
+ const FONT = 'trebuchet ms, verdana, arial, sans-serif'
25
+
26
+ const W = 1120
27
+ const H = 150
28
+ const NODE_W = 250
29
+ const NODE_H = 70
30
+ const CY = H / 2
31
+ const PAD = 30
32
+
33
+ // A left-to-right chain with the SAME three steps as the original diagram;
34
+ // only the last node gained a second line (the two supported backends).
35
+ interface Node {
36
+ x: number
37
+ w: number
38
+ lines: string[]
31
39
  }
32
40
 
33
- const W = 1300
34
- const H = 360
41
+ const nodes: Node[] = [
42
+ { x: PAD, w: NODE_W, lines: ['terminal (you)'] },
43
+ {
44
+ x: PAD + NODE_W + 150,
45
+ w: 260,
46
+ lines: ['zames', 'parse tool-call · run tool'],
47
+ },
48
+ {
49
+ x: PAD + NODE_W + 150 + 260 + 150,
50
+ w: 260,
51
+ lines: ['chat.deepseek.com', 'chat.qwen.ai'],
52
+ },
53
+ ]
35
54
 
36
- const box = (
37
- title: string,
38
- sub: string,
39
- color: string,
40
- ): string =>
41
- '<div class="box" style="border-color:' +
42
- color +
43
- '">' +
44
- '<div class="box-t" style="color:' +
45
- color +
46
- '">' +
47
- title +
48
- '</div>' +
49
- '<div class="box-s">' +
50
- sub +
51
- '</div></div>'
55
+ const esc = (s: string): string =>
56
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
52
57
 
53
- const html =
54
- '<!doctype html><html><head><meta charset="utf-8"><style>' +
55
- '@font-face{font-family:ZM;src:local("DejaVu Sans Mono")}' +
56
- 'html,body{margin:0;padding:0;background:' +
57
- C.bg +
58
- '}' +
59
- 'body{font-family:"DejaVu Sans Mono",monospace;color:' +
60
- C.text +
61
- ';-webkit-font-smoothing:antialiased}' +
62
- '.wrap{box-sizing:border-box;width:' +
58
+ const nodeSvg = (n: Node): string => {
59
+ const y = CY - NODE_H / 2
60
+ const rx = 12
61
+ const lh = 20
62
+ const startY = CY - ((n.lines.length - 1) * lh) / 2 + 5
63
+ const cx = n.x + n.w / 2
64
+ const texts = n.lines
65
+ .map(
66
+ (t, i) =>
67
+ '<text x="' +
68
+ cx +
69
+ '" y="' +
70
+ (startY + i * lh) +
71
+ '" text-anchor="middle" font-family="' +
72
+ FONT +
73
+ '" font-size="15" fill="' +
74
+ TEXT +
75
+ '">' +
76
+ esc(t) +
77
+ '</text>',
78
+ )
79
+ .join('')
80
+ return (
81
+ '<rect x="' +
82
+ n.x +
83
+ '" y="' +
84
+ y +
85
+ '" width="' +
86
+ n.w +
87
+ '" height="' +
88
+ NODE_H +
89
+ '" rx="' +
90
+ rx +
91
+ '" ry="' +
92
+ rx +
93
+ '" fill="' +
94
+ FILL +
95
+ '" stroke="' +
96
+ STROKE +
97
+ '" stroke-width="2"/>' +
98
+ texts
99
+ )
100
+ }
101
+
102
+ // A horizontal edge from x1 to x2 at height y, with a centered label above it.
103
+ const edge = (
104
+ x1: number,
105
+ x2: number,
106
+ y: number,
107
+ label: string,
108
+ dir: 'r' | 'l',
109
+ ): string => {
110
+ const mid = (x1 + x2) / 2
111
+ const marker = dir === 'r' ? 'ar' : 'al'
112
+ return (
113
+ '<path d="M' +
114
+ x1 +
115
+ ' ' +
116
+ y +
117
+ ' L' +
118
+ x2 +
119
+ ' ' +
120
+ y +
121
+ '" fill="none" stroke="' +
122
+ TEXT +
123
+ '" stroke-width="1.6" marker-end="url(#' +
124
+ marker +
125
+ ')"/>' +
126
+ '<text x="' +
127
+ mid +
128
+ '" y="' +
129
+ (y - 8) +
130
+ '" text-anchor="middle" font-family="' +
131
+ FONT +
132
+ '" font-size="12" fill="' +
133
+ TEXT +
134
+ '">' +
135
+ esc(label) +
136
+ '</text>'
137
+ )
138
+ }
139
+
140
+ const a = nodes[0]
141
+ const z = nodes[1]
142
+ const m = nodes[2]
143
+
144
+ const reqY = CY - 16
145
+ const ansY = CY + 20
146
+
147
+ const svg =
148
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' +
149
+ W +
150
+ '" height="' +
151
+ H +
152
+ '" viewBox="0 0 ' +
63
153
  W +
64
- 'px;height:' +
154
+ ' ' +
65
155
  H +
66
- 'px;padding:26px 30px}' +
67
- '.row{display:flex;align-items:center;justify-content:space-between;gap:8px}' +
68
- '.box{box-sizing:border-box;border:2px solid;border-radius:10px;padding:14px 16px;' +
69
- 'background:' +
70
- C.panel +
71
- ';min-width:150px;text-align:center}' +
72
- '.box-t{font-size:18px;font-weight:700;margin-bottom:6px}' +
73
- '.box-s{font-size:12px;color:' +
74
- C.dim +
75
- ';line-height:1.5}' +
76
- '.arrow{font-size:26px;color:' +
77
- C.dim +
78
- '}' +
79
- '.flow-label{font-size:12px;color:' +
80
- C.dim +
81
- ';text-align:center;margin-top:4px}' +
82
- '.providers{display:flex;flex-direction:column;gap:10px}' +
83
- '.col{display:flex;flex-direction:column;align-items:center;gap:4px}' +
84
- '.legend{margin-top:20px;font-size:12px;color:' +
85
- C.dim +
86
- ';text-align:center}' +
87
- '.legend b{color:' +
88
- C.text +
89
- '}' +
90
- '</style></head><body><div class="wrap">' +
91
- '<div class="row">' +
92
- // left: the operator
93
- '<div class="col">' +
94
- box('Operator', 'terminal task<br/>+ tool approval', C.user) +
95
- '<div class="flow-label">types a task</div>' +
96
- '</div>' +
97
- '<div class="arrow">&#8594;</div>' +
98
- // middle: zames
99
- '<div class="col">' +
100
- box(
101
- 'zames',
102
- 'sandboxed tools<br/>parses tool-calls<br/>runs them locally',
103
- C.accent,
104
- ) +
105
- '<div class="flow-label">system prompt + task</div>' +
106
- '</div>' +
107
- '<div class="arrow">&#8644;</div>' +
108
- // right: the two backends
109
- '<div class="providers">' +
110
- box('DeepSeek', 'chat.deepseek.com<br/>~/.zames/profile', C.ds) +
111
- box('Qwen', 'chat.qwen.ai<br/>~/.zames/qwen-profile', C.qwen) +
112
- '</div>' +
113
- '</div>' +
114
- '<div class="legend">zames drives a <b>real browser</b> over a persistent per-provider profile &mdash; no API key. ' +
115
- 'Switch live with <b>/provider &lt;deepseek|qwen&gt;</b> (each chat id belongs to one backend).</div>' +
116
- '</div></body></html>'
156
+ '">' +
157
+ '<defs>' +
158
+ '<marker id="ar" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L9,3 L0,6 z" fill="' +
159
+ TEXT +
160
+ '"/></marker>' +
161
+ '<marker id="al" markerWidth="10" markerHeight="10" refX="1" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M9,0 L0,3 L9,6 z" fill="' +
162
+ TEXT +
163
+ '"/></marker>' +
164
+ '</defs>' +
165
+ '<rect width="100%" height="100%" fill="#ffffff"/>' +
166
+ // request edges (top, left -> right)
167
+ edge(a.x + a.w, z.x, reqY, 'task', 'r') +
168
+ edge(z.x + z.w, m.x, reqY, 'system prompt + task', 'r') +
169
+ // answer edges (bottom, right -> left)
170
+ edge(m.x, z.x + z.w, ansY, 'model answers (raw SSE)', 'l') +
171
+ edge(z.x, a.x + a.w, ansY, 'answer', 'l') +
172
+ nodes.map(nodeSvg).join('') +
173
+ '</svg>'
174
+
175
+ const html =
176
+ '<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:#fff}svg{display:block}</style></head><body>' +
177
+ svg +
178
+ '</body></html>'
117
179
 
118
180
  const browser = await chromium.launch({ headless: true })
119
181
  try {
@@ -122,7 +184,8 @@ try {
122
184
  deviceScaleFactor: 2,
123
185
  })
124
186
  await page.setContent(html, { waitUntil: 'load' })
125
- await page.screenshot({ path: out })
187
+ const el = await page.$('svg')
188
+ await (el || page).screenshot({ path: out })
126
189
  console.log('wrote', path.relative(root, out))
127
190
  } finally {
128
191
  await browser.close()