zames_pro 2.73.7 → 2.73.8

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,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.73.8] - 2026-10-12
11
+
12
+ ### Fixed
13
+
14
+ - bound qwen net body, share attach flow, count transcript backpressure, buffer split paste
15
+ - lose fewer tool args, unstick the arg-error loop, harden launch
16
+
10
17
  ## [2.73.7] - 2026-10-12
11
18
 
12
19
  ### Changed
@@ -232,6 +232,18 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
232
232
  let repeatCallCount = 0;
233
233
  const MAX_REPEAT_CALLS = 4;
234
234
  let repeatWarned = false;
235
+ // Consecutive tool calls that fail with an ARGUMENT error (`Missing required
236
+ // argument`, `Invalid required argument`). Such a call has a DIFFERENT
237
+ // signature each turn (a long `content` changes), so the identical-signature
238
+ // detector above never fires and the model can burn many iterations on the
239
+ // same omission. Track the tool + the error text instead; after a few in a
240
+ // row, nudge with the exact missing field and stop re-running it.
241
+ let argErrSig = '';
242
+ let argErrCount = 0;
243
+ const MAX_ARG_ERRORS = 3;
244
+ // Set when the argument-error series hits the cap; appended to the message
245
+ // AFTER the tool results are formatted (they rebuild `message`).
246
+ let argErrNudge = '';
235
247
  let afterToolRetries = 0;
236
248
  // toolsRanInTask: how many tools actually ran in THIS task. The protocol
237
249
  // guard uses it to tell "started, then slipped into chat mode" (a reasoning
@@ -976,6 +988,45 @@ ${diag}`;
976
988
  durationMs: toolMs,
977
989
  });
978
990
  results.push({ tool: call.tool, result });
991
+ // Argument-error series: `Missing required argument`/`Invalid required
992
+ // argument` from the req() guard. The signature changes each turn when
993
+ // the model forgets one field but rewrites the rest, so the identical
994
+ // detector misses it. Count by tool + error text; at the cap, tell the
995
+ // model EXACTLY which field is missing instead of re-running the call.
996
+ const resStr = String(result);
997
+ const argErr = resStr.match(/(?:Missing|Invalid) required argument[: ]+([A-Za-z0-9_]+)/);
998
+ if (argErr) {
999
+ const sig = call.tool + ':' + argErr[0];
1000
+ if (sig === argErrSig)
1001
+ argErrCount++;
1002
+ else {
1003
+ argErrSig = sig;
1004
+ argErrCount = 1;
1005
+ }
1006
+ if (argErrCount >= MAX_ARG_ERRORS) {
1007
+ argErrCount = 0;
1008
+ argErrSig = '';
1009
+ safeWarning(translate(locale)('msg.arg_error_loop'));
1010
+ // The message the model sees is REBUILT from `results` after the
1011
+ // batch loop, so a nudged `message` here would be overwritten.
1012
+ // Record the nudge and append it after the results are formatted.
1013
+ argErrNudge =
1014
+ 'IMPORTANT: the tool `' +
1015
+ call.tool +
1016
+ '` failed with "' +
1017
+ argErr[0] +
1018
+ '". You keep calling it WITHOUT the required argument `' +
1019
+ argErr[1] +
1020
+ '`. Add `' +
1021
+ argErr[1] +
1022
+ '` to the arguments of the SAME call and retry — do NOT repeat ' +
1023
+ 'the identical invocation.';
1024
+ }
1025
+ }
1026
+ else {
1027
+ argErrSig = '';
1028
+ argErrCount = 0;
1029
+ }
979
1030
  // The operator pressed Esc/Ctrl+C while the tool was running. The tool
980
1031
  // itself has finished (we cannot kill an arbitrary child process from
981
1032
  // here), but we must NOT feed its result back to the model and must NOT
@@ -1023,6 +1074,13 @@ ${diag}`;
1023
1074
  })
1024
1075
  .join('\n\n');
1025
1076
  }
1077
+ // Append the argument-error nudge (set inside the batch loop) now that
1078
+ // `message` has been rebuilt from `results`. Cleared so it fires once per
1079
+ // series, not on every later turn.
1080
+ if (argErrNudge) {
1081
+ message = message + '\n\n' + argErrNudge;
1082
+ argErrNudge = '';
1083
+ }
1026
1084
  // Auto-compact at the ONLY safe seam — after a tool result and before
1027
1085
  // the next send. Never mid-generation, never during a rate-limit wait, and
1028
1086
  // never between the task and the first send (this block runs only after a
@@ -0,0 +1,48 @@
1
+ import fs from 'fs/promises';
2
+ /**
3
+ * Read every file into a Playwright `setFiles` payload. A file that cannot be
4
+ * read is pushed onto `failed` (with a notice through `onReadError`) instead of
5
+ * throwing, so a single unreadable file never aborts the whole attach. Returns
6
+ * the successfully read payloads.
7
+ */
8
+ export async function readAttachBuffers(files, onReadError) {
9
+ const payload = [];
10
+ const failed = [];
11
+ for (const f of files) {
12
+ try {
13
+ const buffer = await fs.readFile(f.path);
14
+ payload.push({ name: f.name, mimeType: f.mime, buffer });
15
+ }
16
+ catch (e) {
17
+ failed.push({ path: f.path, name: f.name });
18
+ onReadError(f.name, e.message);
19
+ }
20
+ }
21
+ return { payload, failed };
22
+ }
23
+ /**
24
+ * Wait for the upload preview (the attached-file chip above the input) to
25
+ * appear, instead of a fixed sleep. The signal is the FILE NAME showing up on
26
+ * the page: files are attached BEFORE the text is typed, so the name cannot
27
+ * appear there for any other reason. `timeoutMs` is the ceiling, so a build
28
+ * where the name is not detected behaves exactly like the old fixed sleep.
29
+ */
30
+ export async function awaitUploadPreview(page, names, timeoutMs) {
31
+ const clean = names.map((n) => String(n || '').trim()).filter(Boolean);
32
+ if (!clean.length) {
33
+ await page.waitForTimeout(timeoutMs);
34
+ return;
35
+ }
36
+ const deadline = Date.now() + timeoutMs;
37
+ while (Date.now() < deadline) {
38
+ for (const name of clean) {
39
+ const n = await page
40
+ .getByText(name, { exact: false })
41
+ .count()
42
+ .catch(() => 0);
43
+ if (n > 0)
44
+ return;
45
+ }
46
+ await page.waitForTimeout(150);
47
+ }
48
+ }
package/dist/browser.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chromium, } from 'playwright';
2
2
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
3
3
  import { rewriteFailedAttachments } from './commands.js';
4
+ import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
4
5
  import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
5
6
  import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, MFA_CODE_SELECTORS, MFA_SUBMIT_SELECTORS, MFA_SEND_CODE_SELECTORS, MFA_SEND_CODE_RE, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
6
7
  import { probeUiHealth, } from './ui-health.js';
@@ -360,7 +361,13 @@ export class DeepSeekBrowser {
360
361
  }
361
362
  this.page = this.context.pages()[0] || (await this.context.newPage());
362
363
  this._installNetHook();
363
- await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
364
+ // A slow network / cold CDN can exceed Playwright's 30s goto timeout.
365
+ // That must NOT abort launch(): the page is usually already usable and
366
+ // waitForLogin() polls. Bailing out made a transient slowness a hard
367
+ // startup failure.
368
+ await this.page
369
+ .goto(CHAT_URL, { waitUntil: 'domcontentloaded' })
370
+ .catch(() => { });
364
371
  }
365
372
  _installNetHook() {
366
373
  const pg = this['page'];
@@ -1516,21 +1523,7 @@ export class DeepSeekBrowser {
1516
1523
  // message to [attach-failed: name]. The model must not believe a file was
1517
1524
  // attached when it was not.
1518
1525
  async _attachFiles(files) {
1519
- const failed = [];
1520
- const bufPayload = [];
1521
- for (const f of files) {
1522
- try {
1523
- const buffer = await fs.readFile(f.path);
1524
- bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
1525
- }
1526
- catch (e) {
1527
- failed.push({ path: f.path, name: f.name });
1528
- this._notice(theme.warn(this._t('ds.attach_read_failed', {
1529
- name: f.name,
1530
- error: e.message,
1531
- })));
1532
- }
1533
- }
1526
+ const { payload: bufPayload, failed } = await readAttachBuffers(files, (name, error) => this._notice(theme.warn(this._t('ds.attach_read_failed', { name, error }))));
1534
1527
  if (!bufPayload.length)
1535
1528
  return failed;
1536
1529
  // Preferred path: click the attach button and let the file chooser event
@@ -1575,30 +1568,11 @@ export class DeepSeekBrowser {
1575
1568
  await this._awaitUploadPreview(files.map((f) => f.name), 2000);
1576
1569
  return failed;
1577
1570
  }
1578
- // Wait for the upload preview (the attached-file chip above the
1579
- // input) to appear, instead of a fixed 2.5s/2s sleep. The signal is the
1580
- // FILE NAME showing up on the page: we attach files BEFORE typing the text,
1581
- // so the name cannot appear there for any other reason. The old fixed value
1582
- // stays as the TIMEOUT CEILING, so a build where the name is not detected
1583
- // behaves exactly as before.
1571
+ // Wait for the upload preview (the attached-file chip above the input) to
1572
+ // appear, instead of a fixed sleep. Delegates to the shared helper so both
1573
+ // providers use the SAME preview policy.
1584
1574
  async _awaitUploadPreview(names, timeoutMs) {
1585
- const clean = names.map((n) => String(n || '').trim()).filter(Boolean);
1586
- if (!clean.length) {
1587
- await this.page.waitForTimeout(timeoutMs);
1588
- return;
1589
- }
1590
- const deadline = Date.now() + timeoutMs;
1591
- while (Date.now() < deadline) {
1592
- for (const name of clean) {
1593
- const n = await this.page
1594
- .getByText(name, { exact: false })
1595
- .count()
1596
- .catch(() => 0);
1597
- if (n > 0)
1598
- return;
1599
- }
1600
- await this.page.waitForTimeout(150);
1601
- }
1575
+ await awaitUploadPreview(this.page, names, timeoutMs);
1602
1576
  }
1603
1577
  // Wait out a rate-limit pause, showing an animated countdown through
1604
1578
  // onSendPause so the operator sees the agent is alive (not frozen). Returns
package/dist/commands.js CHANGED
@@ -1044,8 +1044,12 @@ export const SLASH_COMMANDS = [
1044
1044
  export function buildSlashCommandHints(opts) {
1045
1045
  const { devMode, t } = opts;
1046
1046
  const table = opts.commands ?? SLASH_COMMANDS;
1047
+ // `/model` picks a QWEN model; DeepSeek has no such command. Hide it unless
1048
+ // the Qwen provider is active (or the caller did not pass a provider).
1049
+ const hideModel = opts.provider !== undefined && opts.provider !== 'qwen';
1047
1050
  const base = table
1048
1051
  .filter((c) => devMode || !isDevOnlyCommand(c.name))
1052
+ .filter((c) => !(hideModel && c.name === '/model'))
1049
1053
  .map((c) => ({ name: c.name, description: t(c.key) }));
1050
1054
  const known = new Set(base.map((c) => c.name.toLowerCase()));
1051
1055
  for (const d of opts.dynamic ?? []) {
@@ -178,11 +178,11 @@ export function createExtraTools(workdir, { undo, todos } = {}) {
178
178
  // "undefined" in the working directory.
179
179
  const req = (v, name) => {
180
180
  if (v === undefined || v === null) {
181
- throw new Error(`Missing required argument: ${name}`);
181
+ throw new Error(`Missing required argument: ${name}. Add "${name}" to the tool call arguments and retry.`);
182
182
  }
183
183
  const s = String(v);
184
184
  if (s === '' || s === 'undefined' || s === 'null') {
185
- throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}`);
185
+ throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}. Provide a non-empty value for "${name}" and retry.`);
186
186
  }
187
187
  return s;
188
188
  };
@@ -1,8 +1,8 @@
1
1
  // Common UI strings: app/common, status, tasks, messages, diff, context, undo, cd.
2
2
  export const commonMessages = {
3
3
  'app.tagline': {
4
- ru: 'агент поверх chat.deepseek.com / chat.qwen.ai через Playwright',
5
- en: 'coding agent over chat.deepseek.com / chat.qwen.ai via Playwright',
4
+ ru: 'агент поверх {provider} через Playwright',
5
+ en: 'coding agent over {provider} via Playwright',
6
6
  },
7
7
  'common.yes': { ru: 'да', en: 'yes' },
8
8
  'common.no': { ru: 'нет', en: 'no' },
@@ -91,6 +91,10 @@ export const doctorMessages = {
91
91
  ru: 'Агент повторяет один и тот же вызов инструмента — переключаю на другой подход.',
92
92
  en: 'The agent keeps repeating the same tool call — switching it to a different approach.',
93
93
  },
94
+ 'msg.arg_error_loop': {
95
+ ru: 'Агент раз за разом вызывает инструмент без обязательного аргумента — прошу добавить его.',
96
+ en: 'The agent keeps calling a tool without a required argument — asking it to add the field.',
97
+ },
94
98
  'msg.suspicious_stop': {
95
99
  ru: 'агент, похоже, остановился, не распознав вызов инструмента. Ответ сохранён в транскрипте (событие suspicious_final). Можно попросить продолжить или переотправить задачу.',
96
100
  en: 'the agent seems to have stopped without recognizing a tool call. The response is saved in the transcript (suspicious_final event). You can ask it to continue or resend the task.',
package/dist/i18n/help.js CHANGED
@@ -103,6 +103,7 @@ export const helpMessages = {
103
103
  'help.sec.git': { ru: 'Git', en: 'Git' },
104
104
  'help.sec.agent': { ru: 'Агент', en: 'Agent' },
105
105
  'help.sec.context': { ru: 'Контекст и настройки', en: 'Context & settings' },
106
+ 'help.sec.provider': { ru: 'Провайдер чата', en: 'Chat provider' },
106
107
  'help.sec.files': { ru: 'Файлы и прочее', en: 'Files & misc' },
107
108
  'help.cmd.new': {
108
109
  ru: '/new, /clear новый чат (сброс контекста)',
package/dist/i18n/qwen.js CHANGED
@@ -50,6 +50,14 @@ export const qwenMessages = {
50
50
  ru: 'Не удалось получить список моделей Qwen: {v}',
51
51
  en: 'Failed to fetch the Qwen model list: {v}',
52
52
  },
53
+ 'qwen.model_deepseek_only': {
54
+ ru: '/model доступен только для провайдера Qwen. Сейчас активен DeepSeek (модель выбирается в веб-UI). Переключиться: /provider qwen',
55
+ en: '/model is only available for the Qwen provider. DeepSeek is active (its model is chosen in the web UI). Switch with: /provider qwen',
56
+ },
57
+ 'qwen.age_gate_block': {
58
+ ru: '✖ Qwen показывает окно "Confirm your age" (гостевой режим) и не даёт отправить сообщение. Войдите в аккаунт Qwen (команда /provider login) — в гостевом режиме первое сообщение блокируется.',
59
+ en: '✖ Qwen shows the "Confirm your age" dialog (guest mode) and blocks the send. Sign in to Qwen (/provider login) — in guest mode the first message is blocked.',
60
+ },
53
61
  'provider.switched': {
54
62
  ru: 'Провайдер чата: {v}.',
55
63
  en: 'Chat provider: {v}.',
package/dist/index.js CHANGED
@@ -367,7 +367,11 @@ function printHelp() {
367
367
  t('help.cmd.exit') +
368
368
  '\n';
369
369
  console.log(`
370
- ${theme.bold('zames')} — ${t('app.tagline')}
370
+ ${theme.bold('zames')} — ${t('app.tagline', {
371
+ provider: browserProviderUrl()
372
+ .replace(/^https?:\/\//, '')
373
+ .split('/')[0],
374
+ })}
371
375
 
372
376
  ${theme.bold(t('help.options'))}
373
377
  --dir <path> ${t('help.opt.dir')}
@@ -447,6 +451,9 @@ ${theme.bold(t('help.sec.context'))}
447
451
  ${t('help.cmd.config')}
448
452
  ${t('help.cmd.lang')}
449
453
 
454
+ ${theme.bold(t('help.sec.provider'))}
455
+ ${t('help.cmd.provider')}${config.provider === 'qwen' ? '\n ' + t('help.cmd.model') : ''}
456
+
450
457
  ${theme.bold(t('help.sec.files'))}
451
458
  ${t('help.cmd.transcript')}
452
459
  ${t('help.cmd.undo')}
@@ -576,6 +583,7 @@ function buildSlashCommands() {
576
583
  devMode,
577
584
  t,
578
585
  dynamic: dynamicCommands,
586
+ provider: config.provider,
579
587
  });
580
588
  }
581
589
  // We put the agent's temporary files (one-off scripts, etc.) in
@@ -2208,7 +2216,10 @@ async function main() {
2208
2216
  if (lower === '/model' || lower.startsWith('/model ')) {
2209
2217
  const arg = trimmed.slice('/model'.length).trim();
2210
2218
  if (config.provider !== 'qwen') {
2211
- console.log(theme.system(t('provider.usage')));
2219
+ // `/model` is Qwen-only: it picks a model in Qwen's header dropdown.
2220
+ // DeepSeek exposes no model selection from zames, so the command is
2221
+ // hidden from the hints and only explains itself when typed by hand.
2222
+ console.log(theme.system(t('qwen.model_deepseek_only')));
2212
2223
  continue;
2213
2224
  }
2214
2225
  const qb = browser;
@@ -3501,6 +3512,11 @@ async function main() {
3501
3512
  if (mcpPool)
3502
3513
  await mcpPool.close().catch(() => { });
3503
3514
  await transcript.close();
3515
+ // Surface a transcript that hit backpressure (events buffered in memory, not
3516
+ // on disk) instead of leaving it silent.
3517
+ const trNote = transcript.healthNote();
3518
+ if (trNote)
3519
+ console.log(theme.warn(trNote));
3504
3520
  // One dim line so the operator can find the log and the session after
3505
3521
  // the run (previously the path was only available via /transcript).
3506
3522
  console.log(theme.dim(t('msg.exit_summary', {
@@ -367,3 +367,32 @@ export function formatCompactTokens(tokens) {
367
367
  }
368
368
  // Re-exported for the editor's layout selftest (node src/input.js --selftest).
369
369
  export { NL, CR, ESC, PASTE_START, PASTE_END, safeJson };
370
+ /**
371
+ * Split off a trailing PARTIAL PASTE marker so a control sequence fragmented
372
+ * across two terminal reads is reassembled by the caller instead of leaking
373
+ * into the buffer as literal text. A bracketed paste sends `ESC[200~` …
374
+ * `ESC[201~`; a read can end anywhere inside one of those markers.
375
+ *
376
+ * Returns `{ head, tail }` where `tail` is the held-back partial marker (or
377
+ * null when the string does not end inside a marker). Only tails that START
378
+ * with ESC and are a genuine PREFIX of PASTE_START / PASTE_END are held, so an
379
+ * ordinary trailing `[2` typed by hand is NOT swallowed.
380
+ */
381
+ export function splitPasteTail(s) {
382
+ const str = String(s ?? '');
383
+ if (!str)
384
+ return { head: str, tail: null };
385
+ // The longest marker prefix to consider is one char short of the marker.
386
+ const markers = [PASTE_START, PASTE_END];
387
+ const maxHold = Math.max(...markers.map((m) => m.length)) - 1;
388
+ const upto = Math.min(maxHold, str.length - 1);
389
+ for (let len = upto; len >= 1; len--) {
390
+ const tail = str.slice(str.length - len);
391
+ if (!tail.startsWith(ESC))
392
+ continue;
393
+ if (markers.some((m) => m.length > len && m.startsWith(tail))) {
394
+ return { head: str.slice(0, str.length - len), tail };
395
+ }
396
+ }
397
+ return { head: str, tail: null };
398
+ }
package/dist/input.js CHANGED
@@ -4,12 +4,12 @@ import { contentWidth } from './width.js';
4
4
  import { randomThinkingPhrase, stripEllipsis, DOTS, renderDots, } from './spinner.js';
5
5
  import { translate } from './i18n.js';
6
6
  import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
7
- import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, pasteReplacement, expandPastes, safeJson, visRows, visLen, charWidth, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, } from './input/layout.js';
7
+ import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, pasteReplacement, expandPastes, safeJson, visRows, visLen, charWidth, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, wrapToWidth, splitPasteTail, CONTEXT_LIMIT, } from './input/layout.js';
8
8
  // The pure layout/formatting helpers (visual rows, cursor position, visible
9
9
  // width, paste markers, token formatting) live in ./input/layout.js. They are
10
10
  // re-exported here so every existing import from './input.js' keeps working,
11
11
  // and so tests can import them from either module (C3 step 3).
12
- export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
12
+ export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, splitPasteTail, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
13
13
  // PURE slash-command suggestion logic (match / paging / completion), split out
14
14
  // of the class so the fuzzy match is unit-tested without driving the editor.
15
15
  import { matchSlashCommands, suggestWindow, completeSlashCommand, } from './input/suggest.js';
@@ -99,6 +99,12 @@ export class LineEditor {
99
99
  // head of a control sequence split across two reads. We hold it briefly and
100
100
  // fire onEscape only if no tail arrives.
101
101
  _escPending;
102
+ // Tail of a control sequence split across two `data` chunks. Only the
103
+ // PASTE_START / PASTE_END markers need it: a chunk that ends on `ESC[2` etc.
104
+ // is stored here and re-joined with the next chunk, so a bracketed paste whose
105
+ // start marker was fragmented is still recognized (otherwise the paste bytes
106
+ // leaked into the buffer as literal text).
107
+ _ctrlTail;
102
108
  _escTimer;
103
109
  // History of sent messages (for the up/down arrows).
104
110
  history;
@@ -219,6 +225,7 @@ export class LineEditor {
219
225
  this._resizeTimer = null;
220
226
  this._escPending = false;
221
227
  this._escTimer = null;
228
+ this._ctrlTail = '';
222
229
  this._onResize = () => {
223
230
  // Debounce: a window drag fires a burst of SIGWINCH events. We wait a
224
231
  // short moment, then redraw the block at the NEW width.
@@ -1574,6 +1581,20 @@ export class LineEditor {
1574
1581
  }
1575
1582
  s = ESC + s;
1576
1583
  }
1584
+ // Reattach a control-sequence tail held from the PREVIOUS chunk (a
1585
+ // PASTE_START/PASTE_END marker split across reads). See _ctrlTail.
1586
+ if (this._ctrlTail) {
1587
+ s = this._ctrlTail + s;
1588
+ this._ctrlTail = '';
1589
+ }
1590
+ // A chunk may end in the MIDDLE of a PASTE marker (`ESC[2`, `ESC[20`, …).
1591
+ // Hold that tail back so the next chunk completes it; otherwise the
1592
+ // fragment is inserted as literal text and the paste is not bracketed.
1593
+ const held = splitPasteTail(s);
1594
+ if (held.tail) {
1595
+ this._ctrlTail = held.tail;
1596
+ s = held.head;
1597
+ }
1577
1598
  if (!this._inPaste && s === ESC && !this._searchMode) {
1578
1599
  // Bare ESC in its own chunk: wait a moment for the possible tail of a
1579
1600
  // control sequence before treating it as the Escape key.
@@ -21,12 +21,13 @@ import os from 'os';
21
21
  import { chromium } from 'playwright';
22
22
  import { translate, DEFAULT_LOCALE } from './i18n.js';
23
23
  import { rewriteFailedAttachments } from './commands.js';
24
+ import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
24
25
  import { theme } from './theme.js';
25
26
  import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
26
27
  import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, AUTH_MARKER_FILE, } from './browser-pure.js';
27
28
  import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
28
29
  import { probeUiHealth, } from './ui-health.js';
29
- import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
30
+ import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, AGE_GATE_YEAR_SELECT, AGE_GATE_YEAR_OPTION, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
30
31
  import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, isServerBusyText, } from './qwen-net-capture.js';
31
32
  // A SEPARATE profile from DeepSeek's ~/.zames/profile. Two persistent Chromium
32
33
  // contexts on ONE --user-data-dir cannot coexist (SingletonLock), and keeping
@@ -40,6 +41,18 @@ function escapeRegExp(s) {
40
41
  const CHAT_RE = /chat\.qwen\.ai/i;
41
42
  const ANSWER_URL_RE = /\/api\/v2\/chat\/completions/i;
42
43
  const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
44
+ // Cap the stored RAW SSE body. The truncated/no-answer detectors only read the
45
+ // markers (`quasi_status`, `finish_reason`) and the SLICE shown in errors
46
+ // (first/last 300 chars), so keeping a whole multi-MB stream in memory for the
47
+ // rest of the turn is wasteful. The first 64 KB is plenty for every marker we
48
+ // match; the tail (used in error messages) is kept by cutting the middle.
49
+ const MAX_NET_BODY = 64 * 1024;
50
+ function capNetBody(body) {
51
+ if (body.length <= MAX_NET_BODY)
52
+ return body;
53
+ const head = MAX_NET_BODY - 1024;
54
+ return body.slice(0, head) + '\n...[truncated]...\n' + body.slice(-1024);
55
+ }
43
56
  export class QwenBrowser {
44
57
  providerName = 'qwen';
45
58
  headless;
@@ -268,7 +281,14 @@ export class QwenBrowser {
268
281
  }
269
282
  this.page = this.context.pages()[0] || (await this.context.newPage());
270
283
  this._installNetHook();
271
- await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
284
+ // A slow network (or a cold CDN) can make the initial navigation exceed
285
+ // Playwright's 30s goto timeout. That must NOT abort launch(): the page is
286
+ // usually already usable, and every later step (_awaitInput, waitForLogin)
287
+ // polls. Bailing out here turned a transient slowness into a hard startup
288
+ // failure.
289
+ await this.page
290
+ .goto(CHAT_URL, { waitUntil: 'domcontentloaded' })
291
+ .catch(() => { });
272
292
  await this._dismissOverlays();
273
293
  }
274
294
  async restart() {
@@ -292,6 +312,22 @@ export class QwenBrowser {
292
312
  .catch(() => { });
293
313
  const gate = this.page.locator(AGE_GATE_MODAL);
294
314
  if (await gate.count().catch(() => 0)) {
315
+ // The gate has a YEAR dropdown (default already set) plus Continue.
316
+ // A raw click on Continue can be a no-op until a year is committed,
317
+ // so open the dropdown and pick an option FIRST, then Continue — and
318
+ // finally verify the modal actually closed. Verified live: the year
319
+ // must be selected via the trigger (clicking the label does nothing).
320
+ await this.page
321
+ .locator(AGE_GATE_YEAR_SELECT)
322
+ .first()
323
+ .click({ timeout: 2000 })
324
+ .catch(() => { });
325
+ await this.page.waitForTimeout(400);
326
+ const opt = this.page.locator(AGE_GATE_YEAR_OPTION).first();
327
+ if (await opt.count().catch(() => 0)) {
328
+ await opt.click({ timeout: 2000 }).catch(() => { });
329
+ await this.page.waitForTimeout(300);
330
+ }
295
331
  await this.page
296
332
  .locator(AGE_GATE_CONTINUE)
297
333
  .first()
@@ -360,7 +396,7 @@ export class QwenBrowser {
360
396
  // tomorrow).
361
397
  if (isGuestLimitText(body)) {
362
398
  this._netGuestLimit = true;
363
- this._netBody = body;
399
+ this._netBody = capNetBody(body);
364
400
  this._netCaptureAt = Date.now();
365
401
  }
366
402
  const isAnswerUrl = ANSWER_URL_RE.test(url);
@@ -377,7 +413,7 @@ export class QwenBrowser {
377
413
  // sent it by hand. Bump the throttle so our next send waits.
378
414
  this._lastSentAt = Date.now();
379
415
  }
380
- this._netBody = body;
416
+ this._netBody = capNetBody(body);
381
417
  const cid = body.match(UUID_RE);
382
418
  if (cid)
383
419
  this._netChatId = cid[0];
@@ -1323,6 +1359,11 @@ export class QwenBrowser {
1323
1359
  if (e instanceof QwenGuestLimitError) {
1324
1360
  throw new Error(this._t('qwen.guest_limit'));
1325
1361
  }
1362
+ if (e instanceof QwenAgeGateError) {
1363
+ // Not retryable: a resend hits the SAME gate. Fail at once with a
1364
+ // clear "sign in" message instead of burning the retry budget.
1365
+ throw new Error(this._t('qwen.age_gate_block'));
1366
+ }
1326
1367
  if (/rate limit|too frequent/i.test(msg)) {
1327
1368
  if (rateLimitRetries >= this.maxRateLimitRetries)
1328
1369
  break;
@@ -1400,21 +1441,7 @@ export class QwenBrowser {
1400
1441
  // the input). Returns the files that FAILED so the prompt can rewrite their
1401
1442
  // markers. Mirrors DeepSeekBrowser._attachFiles.
1402
1443
  async _attachFiles(files) {
1403
- const failed = [];
1404
- const bufPayload = [];
1405
- for (const f of files) {
1406
- try {
1407
- const buffer = await fs.readFile(f.path);
1408
- bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
1409
- }
1410
- catch (e) {
1411
- failed.push({ path: f.path, name: f.name });
1412
- this._notice(theme.warn(this._t('qwen.attach_read_failed', {
1413
- name: f.name,
1414
- error: e.message,
1415
- })));
1416
- }
1417
- }
1444
+ const { payload: bufPayload, failed } = await readAttachBuffers(files, (name, error) => this._notice(theme.warn(this._t('qwen.attach_read_failed', { name, error }))));
1418
1445
  if (!bufPayload.length)
1419
1446
  return failed;
1420
1447
  // Primary flow (verified live): click the "+" (`.mode-select-open`) to
@@ -1459,27 +1486,10 @@ export class QwenBrowser {
1459
1486
  failed.push({ path: f.path, name: f.name });
1460
1487
  return failed;
1461
1488
  }
1462
- // Wait for the upload chip (the file name above the input) instead of a
1463
- // fixed sleep: files are attached BEFORE the text is typed, so the name
1464
- // cannot appear on the page for any other reason.
1489
+ // Wait for the upload chip (the file name above the input). Delegates to the
1490
+ // shared helper so both providers use the SAME preview policy.
1465
1491
  async _awaitUploadPreview(names, timeoutMs) {
1466
- const clean = names.map((n) => String(n || '').trim()).filter(Boolean);
1467
- if (!clean.length) {
1468
- await this.page.waitForTimeout(timeoutMs);
1469
- return;
1470
- }
1471
- const deadline = Date.now() + timeoutMs;
1472
- while (Date.now() < deadline) {
1473
- for (const name of clean) {
1474
- const n = await this.page
1475
- .getByText(name, { exact: false })
1476
- .count()
1477
- .catch(() => 0);
1478
- if (n > 0)
1479
- return;
1480
- }
1481
- await this.page.waitForTimeout(150);
1482
- }
1492
+ await awaitUploadPreview(this.page, names, timeoutMs);
1483
1493
  }
1484
1494
  async _setInputText(input, text) {
1485
1495
  const tag = await input.evaluate((el) => el.tagName.toLowerCase());
@@ -1572,6 +1582,11 @@ export class QwenBrowser {
1572
1582
  const preSendSignal = await this._chatSignal();
1573
1583
  let sent = false;
1574
1584
  while (!sent && Date.now() < sendDeadline) {
1585
+ // The age-confirmation gate can POP UP on the FIRST send (it is not
1586
+ // present at page load). While it is on screen it swallows the click on
1587
+ // the send button, so the message never goes out. Dismiss it BEFORE each
1588
+ // send attempt; a no-op once it is gone.
1589
+ await this._dismissOverlays();
1575
1590
  // (1) the send button, if present/enabled/visible. We remember whether a
1576
1591
  // click was actually issued: pressing Enter in the SAME round could send
1577
1592
  // the message a SECOND time if the click already worked but the input
@@ -1626,8 +1641,19 @@ export class QwenBrowser {
1626
1641
  this._lastSentAt = Date.now();
1627
1642
  if (!sent) {
1628
1643
  // The text is still in the input after the whole deadline: the message
1629
- // was NOT sent. Throw so ask() retries the send instead of waiting out
1630
- // the full answer timeout for a turn that never started.
1644
+ // was NOT sent. A common cause is the age-confirmation gate that pops up
1645
+ // for a GUEST and (verified live) does not always close programmatically
1646
+ // — it swallows the click on the send button. Report that explicitly so
1647
+ // the operator knows to sign in instead of seeing a generic error.
1648
+ const gate = await this.page
1649
+ .locator(AGE_GATE_MODAL)
1650
+ .count()
1651
+ .catch(() => 0);
1652
+ if (gate > 0) {
1653
+ throw new QwenAgeGateError('age-confirmation modal blocks the send');
1654
+ }
1655
+ // Throw so ask() retries the send instead of waiting out the full answer
1656
+ // timeout for a turn that never started.
1631
1657
  throw new Error(this._t('qwen.send_button_missing'));
1632
1658
  }
1633
1659
  // Wait for the answer to start, then for it to settle.
@@ -1729,6 +1755,14 @@ class QwenGuestLimitError extends Error {
1729
1755
  this.name = 'QwenGuestLimitError';
1730
1756
  }
1731
1757
  }
1758
+ // The guest age-confirmation gate swallowed the send. NOT retryable: a resend
1759
+ // hits the same gate, so ask() fails at once instead of burning its retries.
1760
+ class QwenAgeGateError extends Error {
1761
+ constructor(detail) {
1762
+ super('age gate: ' + detail);
1763
+ this.name = 'QwenAgeGateError';
1764
+ }
1765
+ }
1732
1766
  class QwenIncompleteError extends Error {
1733
1767
  constructor(detail) {
1734
1768
  super('generation incomplete: ' + detail);
package/dist/qwen-ui.js CHANGED
@@ -80,7 +80,13 @@ export const MODEL_TRIGGER_SELECTOR = '.wms-trigger';
80
80
  // A first-time visitor must confirm their birth year before chatting. The
81
81
  // dialog is an ant-modal; the Continue button is the black footer button.
82
82
  export const AGE_GATE_MODAL = '.age-confirmation-modal';
83
+ // The Continue button inside the age gate (the black footer button).
83
84
  export const AGE_GATE_CONTINUE = '.age-confirmation-modal .qwen-chat-v2-btn-black';
85
+ // The year dropdown TRIGGER inside the age gate. It must be clicked to open
86
+ // the year menu; clicking the label alone does not select a year.
87
+ export const AGE_GATE_YEAR_SELECT = '.age-confirmation-year .qwen-chat-v2-dropdown-menu-select';
88
+ // A year OPTION in the opened dropdown (the menu item text is the year).
89
+ export const AGE_GATE_YEAR_OPTION = '.qwen-chat-v2-dropdown-menu-item';
84
90
  // The cookie banner's accept button (matched by its visible label).
85
91
  export const COOKIE_ACCEPT_RE = /^accept all cookies$/i;
86
92
  // ---------- messages / answers ----------
package/dist/shell.js CHANGED
@@ -43,6 +43,22 @@ export function assertCommandInsideRoot(root, command) {
43
43
  throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
44
44
  }
45
45
  }
46
+ // Indirect re-parse / chdir that a static `cd` scan cannot follow. These are
47
+ // the documented escape hatches (`sh -c 'cd /'`, `python -c
48
+ // "os.chdir('/')"`): they execute a fresh interpreter which reads the target
49
+ // only at RUNTIME, so no static scan can prove it stays inside the root.
50
+ // Refusing them is the honest barrier — best-effort, not OS isolation.
51
+ if (/\b(?:sh|bash|dash|zsh|ksh)\b[^\n]*\s-[a-z]*c\b/i.test(cmd)) {
52
+ throw new Error('Sandbox: running a shell via -c is forbidden (cannot verify the root)');
53
+ }
54
+ if (/\bchroot\b/i.test(cmd)) {
55
+ throw new Error('Sandbox: chroot is forbidden');
56
+ }
57
+ // A chdir performed by an interpreter (Python/Perl/Ruby/Node) or by a raw
58
+ // syscall wrapper. Matched loosely on the common spellings.
59
+ if (/\bos\.chdir\s*\(|\bchdir\s*\(|\bProcess\.chdir\b|process\.chdir\s*\(|\bchdir\b\s*['"]?\//i.test(cmd)) {
60
+ throw new Error('Sandbox: an in-process directory change (chdir) is forbidden');
61
+ }
46
62
  }
47
63
  function formatExecResult(err, stdout, stderr, timeout, okExitCodes) {
48
64
  const out = (stdout || '').toString();
package/dist/tools.js CHANGED
@@ -40,11 +40,11 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
40
40
  };
41
41
  const req = (v, name) => {
42
42
  if (v === undefined || v === null) {
43
- throw new Error(`Missing required argument: ${name}`);
43
+ throw new Error(`Missing required argument: ${name}. Add "${name}" to the tool call arguments and retry.`);
44
44
  }
45
45
  const s = String(v);
46
46
  if (s === '' || s === 'undefined' || s === 'null') {
47
- throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}`);
47
+ throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}. Provide a non-empty value for "${name}" and retry.`);
48
48
  }
49
49
  return s;
50
50
  };
@@ -49,10 +49,19 @@ export class Transcript {
49
49
  if (!this.enabled || !this.stream)
50
50
  return;
51
51
  try {
52
- this.stream.write(line);
52
+ // `write() === false` means the internal buffer is FULL (slow disk /
53
+ // network mount). Node keeps buffering in memory, so a long run of fast
54
+ // events can grow unboundedly; we COUNT the backpressured writes so
55
+ // close() can report them. We still write (dropping trace lines silently
56
+ // would be worse), but the counter makes the condition visible.
57
+ const ok = this.stream.write(line);
58
+ if (!ok)
59
+ this.backpressure++;
53
60
  }
54
61
  catch { }
55
62
  }
63
+ /** Number of writes that hit backpressure (write() === false). */
64
+ backpressure = 0;
56
65
  // Flush the stream and resolve once the data reached the file. The callers
57
66
  // run on process.exit paths (one-shot --task, browser error, clean exit), and
58
67
  // end() without awaiting the callback lets the process die before the buffer
@@ -70,4 +79,16 @@ export class Transcript {
70
79
  stream.end(() => resolve());
71
80
  });
72
81
  }
82
+ /**
83
+ * A one-line health note for the operator, or null when the stream stayed
84
+ * healthy. Callers print it on exit so a slow/broken transcript file (the
85
+ * events were buffered in memory instead of reaching disk) is not silent.
86
+ */
87
+ healthNote() {
88
+ if (this.backpressure === 0)
89
+ return null;
90
+ return ('transcript: ' +
91
+ this.backpressure +
92
+ ' write(s) hit backpressure (slow disk?) — events may be delayed');
93
+ }
73
94
  }
@@ -18,6 +18,26 @@ function readAttr(attrs, name) {
18
18
  const m = attrs.match(re);
19
19
  return m ? m[2] : null;
20
20
  }
21
+ // Read EVERY key="value" pair from an <invoke ...> tag. The model sometimes
22
+ // emits a tool argument as an ATTRIBUTE of the invoke tag itself (e.g.
23
+ // `<invoke name="Write" path="chess.html">` with only the big `content` as a
24
+ // <parameter> child). readAttr() kept only the tool name, so such an argument
25
+ // was silently DROPPED and the tool failed with 'Missing required argument'.
26
+ // Keys are returned in source order so the first occurrence wins.
27
+ function readAllAttrs(attrs) {
28
+ const Q = String.fromCharCode(34);
29
+ const out = {};
30
+ const re = new RegExp('([A-Za-z_][A-Za-z0-9_]*)[ ]*=[ ]*[' + Q + ']([^' + Q + ']*)[' + Q + ']', 'g');
31
+ let m;
32
+ while ((m = re.exec(attrs)) !== null) {
33
+ const key = m[1];
34
+ if (key === 'name')
35
+ continue;
36
+ if (!(key in out))
37
+ out[key] = unescapeXml(m[2]);
38
+ }
39
+ return out;
40
+ }
21
41
  // The model sometimes produces a "hybrid" call: the tool name is an attribute
22
42
  // AND the arguments are inline JSON in the SAME opening tag, without any
23
43
  // <parameter> children. A real case from the transcript:
@@ -127,6 +147,15 @@ export function parseXmlToolCalls(text) {
127
147
  const closeMatch = closeRe.exec(rest);
128
148
  const body = closeMatch ? rest.slice(0, closeMatch.index) : rest;
129
149
  const args = parseParameters(body);
150
+ // Arguments may ALSO arrive as attributes of the invoke tag itself
151
+ // (`<invoke name="Write" path="chess.html">`); merge them in, letting an
152
+ // explicit <parameter> of the same name win (it carries string=true and
153
+ // the exact value). This is what stops `path` from being silently lost.
154
+ const attrArgs = readAllAttrs(attrs);
155
+ for (const k of Object.keys(attrArgs)) {
156
+ if (!(k in args))
157
+ args[k] = attrArgs[k];
158
+ }
130
159
  // Hybrid form: the arguments are inline JSON in the SAME invoke tag
131
160
  // (e.g. `<|DSML|invoke name="GitAdd", "args" {"paths": "..."}>`), with no
132
161
  // <parameter> children. Fall back to the raw invoke head + body.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.73.7",
3
+ "version": "2.73.8",
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": {
@@ -163,6 +163,38 @@ async function main() {
163
163
  } catch (e) {
164
164
  record('toolCall', false, (e as Error).message)
165
165
  }
166
+
167
+ // History restore: after the sends above the chat must have messages.
168
+ try {
169
+ const id = await browser.getCurrentChatId()
170
+ const msgs = id ? await browser.fetchChatMessages(id) : []
171
+ record(
172
+ 'history',
173
+ msgs.length > 0,
174
+ 'id=' + (id || '-') + ' msgs=' + msgs.length,
175
+ )
176
+ } catch (e) {
177
+ record('history', false, (e as Error).message)
178
+ }
179
+
180
+ // Attachments: a SIGNED-IN session uploads a file; a GUEST cannot, so a
181
+ // guest failure is reported as a SKIP (not a failure) — the guest limit is
182
+ // a site restriction, not a regression.
183
+ try {
184
+ const tmpFile = path.join(SMOKE_HOME, 'zames-smoke-note.txt')
185
+ fs.writeFileSync(tmpFile, 'The secret word is BANANA')
186
+ const failed = await browser._attachFiles([
187
+ { path: tmpFile, name: 'zames-smoke-note.txt', mime: 'text/plain' },
188
+ ])
189
+ const attached = failed.length === 0
190
+ const isGuest = !(await browser.isLoggedIn().catch(() => false))
191
+ if (attached) record('attachments', true, '')
192
+ else if (isGuest)
193
+ record('attachments', true, 'skipped (guest cannot upload)')
194
+ else record('attachments', false, JSON.stringify(failed))
195
+ } catch (e) {
196
+ record('attachments', false, (e as Error).message)
197
+ }
166
198
  }
167
199
 
168
200
  await browser.close().catch(() => {})