zames_pro 2.73.1 → 2.73.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 +12 -0
- package/README.md +55 -41
- package/dist/attachments.js +14 -10
- package/dist/checkpoint.js +19 -2
- package/dist/config.js +6 -0
- package/dist/context.js +18 -2
- package/dist/gitTools.js +20 -4
- package/dist/index.js +7 -1
- package/dist/input.js +8 -19
- package/dist/mcp.js +23 -7
- package/dist/sessions.js +9 -4
- package/dist/shell.js +9 -0
- package/dist/transcript.js +3 -3
- package/dist/undo.js +7 -1
- package/dist/web.js +35 -2
- package/package.json +3 -1
- package/scripts/render-how-it-works.mts +129 -0
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.3] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- harden SSRF guard, sandbox, git args, mcp lifecycle and editor
|
|
15
|
+
|
|
16
|
+
## [2.73.2] - 2026-10-11
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- docs(readme): document Qwen everywhere and refresh the diagram
|
|
21
|
+
|
|
10
22
|
## [2.73.1] - 2026-10-11
|
|
11
23
|
|
|
12
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -63,27 +63,31 @@ zames does not call the model API. It launches a headless Chromium with a
|
|
|
63
63
|
persistent profile, signs in to a chat web app like a human, types the task
|
|
64
64
|
into the chat box, and reads the answer back.
|
|
65
65
|
|
|
66
|
-
Two chat backends are available, switched with `/provider <deepseek|qwen
|
|
67
|
-
|
|
68
|
-
**
|
|
69
|
-
|
|
66
|
+
Two chat backends are available, switched with `/provider <deepseek|qwen>`:
|
|
67
|
+
**DeepSeek** (`chat.deepseek.com`, the default) and **Qwen** (`chat.qwen.ai`).
|
|
68
|
+
The switch is **live** — zames closes the current backend and opens the other
|
|
69
|
+
one in place, no restart needed. Each keeps its own persistent profile
|
|
70
|
+
(`~/.zames/profile` and `~/.zames/qwen-profile`), so switching never signs the
|
|
71
|
+
other one out.
|
|
70
72
|
|
|
71
73
|
<p align="center">
|
|
72
74
|
<img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/how-it-works.png" alt="how zames works" width="860">
|
|
73
75
|
</p>
|
|
74
76
|
|
|
75
77
|
In text: you type a task → zames sends the system prompt plus the task into
|
|
76
|
-
the
|
|
77
|
-
the tool locally, feeds the result back, and prints the
|
|
78
|
+
the active chat web app (DeepSeek or Qwen) → the model answers → zames parses
|
|
79
|
+
the tool call, runs the tool locally, feeds the result back, and prints the
|
|
80
|
+
final answer for you.
|
|
78
81
|
|
|
79
82
|
Key pieces:
|
|
80
83
|
|
|
81
84
|
- **The answer is read from the raw network stream** (SSE), not the rendered
|
|
82
85
|
DOM, so tool-call JSON with template strings and escapes survives intact.
|
|
83
|
-
- **A persistent profile** (`~/.zames/profile`
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
|
|
86
|
+
- **A persistent per-provider profile** (`~/.zames/profile` for DeepSeek,
|
|
87
|
+
`~/.zames/qwen-profile` for Qwen) keeps you signed in; the headless
|
|
88
|
+
User-Agent is patched so the site's CDN does not 403 the login.
|
|
89
|
+
- **A send throttle** (15 s on DeepSeek, ~1 s on Qwen, which tolerates rapid
|
|
90
|
+
sends) keeps the web chat's rate limit happy during long tool-heavy runs.
|
|
87
91
|
- **The agent is sandboxed** to the directory it was started in — no tool can
|
|
88
92
|
read or write above it.
|
|
89
93
|
|
|
@@ -174,7 +178,7 @@ zames can drive either **DeepSeek** (`chat.deepseek.com`, the default) or
|
|
|
174
178
|
**Qwen** (`chat.qwen.ai`):
|
|
175
179
|
|
|
176
180
|
```bash
|
|
177
|
-
/provider qwen # switch to Qwen (persisted)
|
|
181
|
+
/provider qwen # switch to Qwen (persisted) — applied live, no restart
|
|
178
182
|
/provider deepseek # switch back
|
|
179
183
|
/provider status # show the active provider / model / login state
|
|
180
184
|
/provider login # (re)run the sign-in check
|
|
@@ -184,15 +188,18 @@ zames can drive either **DeepSeek** (`chat.deepseek.com`, the default) or
|
|
|
184
188
|
|
|
185
189
|
Notes:
|
|
186
190
|
|
|
187
|
-
- The
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
- The switch is **live**: zames closes the current backend and opens the other
|
|
192
|
+
one in place, so no restart is needed. The two backends use **separate
|
|
193
|
+
persistent profiles** (`~/.zames/profile` for DeepSeek, `~/.zames/qwen-profile`
|
|
194
|
+
for Qwen), so switching never signs the other one out.
|
|
195
|
+
- A chat id belongs to ONE backend, so the new provider opens a **fresh chat**.
|
|
196
|
+
If the previous chat had messages they are summarized and posted as a
|
|
197
|
+
handoff with the next task; an empty chat is switched silently.
|
|
191
198
|
- Qwen works in **guest mode** without an account, but a guest is limited to a
|
|
192
199
|
handful of messages per day; for long agent runs sign in (email / Google /
|
|
193
200
|
GitHub — no phone required). zames auto-logins from the saved credentials or
|
|
194
201
|
asks in the terminal, exactly like DeepSeek. `/model` changes the Qwen model
|
|
195
|
-
at runtime
|
|
202
|
+
at runtime.
|
|
196
203
|
- File attachments on Qwen go through the mode menu's **Upload attachment**
|
|
197
204
|
item; DeepSeek uses its own paperclip.
|
|
198
205
|
- `/doctor` shows the active provider and the UI health of that provider's
|
|
@@ -343,9 +350,10 @@ Codex CLI:
|
|
|
343
350
|
(zames-export-<stamp>.md by default).
|
|
344
351
|
- /doctor — diagnose node, git, config, browser, clipboard, MCP and the
|
|
345
352
|
active chat provider (it also reports that provider's UI health).
|
|
346
|
-
- /provider <deepseek|qwen> — switch the chat backend (
|
|
347
|
-
/provider status shows the active provider/model/login; /provider
|
|
348
|
-
re-runs the sign-in check. /model [id] lists or picks a Qwen model at
|
|
353
|
+
- /provider <deepseek|qwen> — switch the chat backend (applied live, no
|
|
354
|
+
restart); /provider status shows the active provider/model/login; /provider
|
|
355
|
+
login re-runs the sign-in check. /model [id] lists or picks a Qwen model at
|
|
356
|
+
runtime.
|
|
349
357
|
- /add-dir <path> — validate an extra directory (the sandbox is fixed at
|
|
350
358
|
startup; relaunch with --dir to write there).
|
|
351
359
|
- /resume <n> (after /chats) and /resume-id <id> — open a chat and PRINT its
|
|
@@ -582,30 +590,33 @@ zames не вызывает API модели. Он запускает headless C
|
|
|
582
590
|
профилем, входит в веб-чат как человек, печатает задачу в поле чата и читает
|
|
583
591
|
ответ обратно.
|
|
584
592
|
|
|
585
|
-
Доступны два бэкенда, переключаются командой `/provider <deepseek|qwen
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
593
|
+
Доступны два бэкенда, переключаются командой `/provider <deepseek|qwen>`:
|
|
594
|
+
**DeepSeek** (`chat.deepseek.com`, по умолчанию) и **Qwen** (`chat.qwen.ai`).
|
|
595
|
+
Переключение **живое** — zames закрывает текущий бэкенд и открывает другой на
|
|
596
|
+
месте, без перезапуска. У каждого свой постоянный профиль
|
|
597
|
+
(`~/.zames/profile` и `~/.zames/qwen-profile`), поэтому переключение не
|
|
598
|
+
разлогинивает другой.
|
|
589
599
|
|
|
590
600
|
<p align="center">
|
|
591
601
|
<img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/how-it-works.png" alt="как работает zames" width="860">
|
|
592
602
|
</p>
|
|
593
603
|
|
|
594
604
|
Словами: вы вводите задачу → zames отправляет системный промпт и задачу в
|
|
595
|
-
веб-чат DeepSeek → модель отвечает → zames разбирает
|
|
596
|
-
выполняет его локально, возвращает результат обратно и
|
|
597
|
-
ответ.
|
|
605
|
+
активный веб-чат (DeepSeek или Qwen) → модель отвечает → zames разбирает
|
|
606
|
+
вызов инструмента, выполняет его локально, возвращает результат обратно и
|
|
607
|
+
печатает финальный ответ.
|
|
598
608
|
|
|
599
609
|
Ключевые детали:
|
|
600
610
|
|
|
601
611
|
- **Ответ читается из сырого сетевого потока** (SSE), а не из отрендеренного
|
|
602
612
|
DOM, поэтому JSON вызова инструмента с шаблонными строками и экранированием
|
|
603
613
|
остаётся целым.
|
|
604
|
-
- **Постоянный
|
|
605
|
-
|
|
606
|
-
на вход.
|
|
607
|
-
- **Троттлинг отправки** (15 с
|
|
608
|
-
|
|
614
|
+
- **Постоянный профиль на каждого провайдера** (`~/.zames/profile` у DeepSeek,
|
|
615
|
+
`~/.zames/qwen-profile` у Qwen) держит вас залогиненным; User-Agent
|
|
616
|
+
headless-браузера подменяется, чтобы CDN сайта не отдавал 403 на вход.
|
|
617
|
+
- **Троттлинг отправки** (15 с у DeepSeek, ~1 с у Qwen, который спокойно
|
|
618
|
+
переносит частые отправки) щадит рейт-лимит веб-чата во время долгих
|
|
619
|
+
прогонов с инструментами.
|
|
609
620
|
- **Агент запесочен** в директорию запуска — ни один инструмент не может
|
|
610
621
|
читать или писать выше неё.
|
|
611
622
|
|
|
@@ -698,7 +709,7 @@ zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по у
|
|
|
698
709
|
**Qwen** (`chat.qwen.ai`):
|
|
699
710
|
|
|
700
711
|
```bash
|
|
701
|
-
/provider qwen # переключиться на Qwen (сохраняется)
|
|
712
|
+
/provider qwen # переключиться на Qwen (сохраняется) — применяется сразу
|
|
702
713
|
/provider deepseek # вернуться обратно
|
|
703
714
|
/provider status # активный провайдер / модель / статус входа
|
|
704
715
|
/provider login # повторно пройти проверку входа
|
|
@@ -708,15 +719,17 @@ zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по у
|
|
|
708
719
|
|
|
709
720
|
Замечания:
|
|
710
721
|
|
|
711
|
-
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
722
|
+
- Переключение **живое**: zames закрывает текущий бэкенд и открывает другой на
|
|
723
|
+
месте, перезапуск не нужен. У бэкендов **разные постоянные профили**
|
|
724
|
+
(`~/.zames/profile` у DeepSeek, `~/.zames/qwen-profile` у Qwen), поэтому
|
|
725
|
+
переключение не разлогинивает другой.
|
|
726
|
+
- id чата принадлежит ОДНОМУ бэкенду, поэтому новый провайдер открывает
|
|
727
|
+
**свежий чат**. Если в прошлом чате были сообщения, они сжимаются и
|
|
728
|
+
подставляются как handoff в следующую задачу; пустой чат переключается молча.
|
|
715
729
|
- Qwen работает в **гостевом режиме** без аккаунта, но гостю доступно всего
|
|
716
730
|
несколько сообщений в день; для долгих прогонов войдите (email / Google /
|
|
717
731
|
GitHub — телефон не нужен). zames сам логинится по сохранённым кредам или
|
|
718
|
-
спросит в терминале, как и с DeepSeek. `/model` меняет модель Qwen на
|
|
719
|
-
(без перезапуска).
|
|
732
|
+
спросит в терминале, как и с DeepSeek. `/model` меняет модель Qwen на лету.
|
|
720
733
|
- Вложения в Qwen идут через пункт **Upload attachment** в меню режима; в
|
|
721
734
|
DeepSeek — своя скрепка.
|
|
722
735
|
- `/doctor` показывает активный провайдер и состояние UI его страницы.
|
|
@@ -872,9 +885,10 @@ zames --task "..." --output-format jsonl \
|
|
|
872
885
|
(по умолчанию zames-export-<stamp>.md).
|
|
873
886
|
- /doctor — диагностика node, git, конфига, браузера, буфера обмена, MCP и
|
|
874
887
|
активного провайдера чата (плюс состояние UI его страницы).
|
|
875
|
-
- /provider <deepseek|qwen> — сменить бэкенд чата (
|
|
876
|
-
/provider status показывает активный провайдер/модель/вход;
|
|
877
|
-
повторно проверяет вход. /model [id] покажет или выберет
|
|
888
|
+
- /provider <deepseek|qwen> — сменить бэкенд чата (применяется сразу, без
|
|
889
|
+
перезапуска); /provider status показывает активный провайдер/модель/вход;
|
|
890
|
+
/provider login повторно проверяет вход. /model [id] покажет или выберет
|
|
891
|
+
модель Qwen на лету.
|
|
878
892
|
- /add-dir <path> — проверить дополнительную директорию (песочница
|
|
879
893
|
фиксируется при старте; для записи туда перезапустите с --dir).
|
|
880
894
|
- /resume <n> (после /chats) и /resume-id <id> — открыть чат и НАПЕЧАТАТЬ его
|
package/dist/attachments.js
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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() {
|
package/dist/checkpoint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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 (
|
|
905
|
-
|
|
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
|
|
47
|
+
if (args.some((a) => a.startsWith('--isolated') ||
|
|
48
48
|
a.startsWith('--user-data-dir') ||
|
|
49
|
-
a
|
|
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
|
|
298
|
-
|
|
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
|
|
324
|
-
const listed = await
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
package/dist/transcript.js
CHANGED
|
@@ -64,10 +64,10 @@ export class Transcript {
|
|
|
64
64
|
if (!stream)
|
|
65
65
|
return Promise.resolve();
|
|
66
66
|
return new Promise((resolve) => {
|
|
67
|
-
|
|
68
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.73.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": {
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"self-smoke": "tsx scripts/self-smoke.mts",
|
|
36
36
|
"qwen-smoke": "tsx scripts/qwen-smoke.mts",
|
|
37
37
|
"render-demo": "tsx scripts/render-demo.mts",
|
|
38
|
+
"render-how": "tsx scripts/render-how-it-works.mts",
|
|
39
|
+
"render-how": "tsx scripts/render-how-it-works.mts",
|
|
38
40
|
"build:readme": "tsx scripts/build-readme.mts",
|
|
39
41
|
"postinstall": "node scripts/postinstall.mjs",
|
|
40
42
|
"prepare": "husky"
|
|
@@ -0,0 +1,129 @@
|
|
|
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.
|
|
7
|
+
//
|
|
8
|
+
// Run: npm run render-how (tsx scripts/render-how-it-works.mts)
|
|
9
|
+
//
|
|
10
|
+
// Isolation: chromium.launch() uses a THROWAWAY in-memory context, never
|
|
11
|
+
// ~/.zames/profile (see AGENTS.md — never touch the live agent's browser).
|
|
12
|
+
|
|
13
|
+
import { chromium } from 'playwright'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
const out = path.join(root, 'docs', 'how-it-works.png')
|
|
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',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const W = 1300
|
|
34
|
+
const H = 360
|
|
35
|
+
|
|
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>'
|
|
52
|
+
|
|
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:' +
|
|
63
|
+
W +
|
|
64
|
+
'px;height:' +
|
|
65
|
+
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">→</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">⇄</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 — no API key. ' +
|
|
115
|
+
'Switch live with <b>/provider <deepseek|qwen></b> (each chat id belongs to one backend).</div>' +
|
|
116
|
+
'</div></body></html>'
|
|
117
|
+
|
|
118
|
+
const browser = await chromium.launch({ headless: true })
|
|
119
|
+
try {
|
|
120
|
+
const page = await browser.newPage({
|
|
121
|
+
viewport: { width: W, height: H },
|
|
122
|
+
deviceScaleFactor: 2,
|
|
123
|
+
})
|
|
124
|
+
await page.setContent(html, { waitUntil: 'load' })
|
|
125
|
+
await page.screenshot({ path: out })
|
|
126
|
+
console.log('wrote', path.relative(root, out))
|
|
127
|
+
} finally {
|
|
128
|
+
await browser.close()
|
|
129
|
+
}
|