zames_pro 2.66.0 → 2.67.0

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.
@@ -63,12 +63,51 @@ function safeRegex(source) {
63
63
  // A file path may arrive in any of these keys depending on the tool.
64
64
  // We collect them all and match the path regex against ANY of them.
65
65
  const PATH_KEYS = ['path', 'file_path', 'notebook_path', 'from', 'to'];
66
+ // Best-effort check whether a string looks like a filesystem path. Used for
67
+ // tools whose arguments are not covered by PATH_KEYS (MCP tools take arbitrary
68
+ // arg names from their JSON Schema), so a `path` rule is not silently
69
+ // bypassed just because the tool named the field differently.
70
+ function looksLikePath(v) {
71
+ return /[\\/]/.test(v) || /^\.[.\\/]/.test(v) || /^[A-Za-z]:[\\/]/.test(v);
72
+ }
73
+ // Collect every path-ish string from a tool call's arguments. ApplyPatch keeps
74
+ // the paths INSIDE the patch text (not in a `path` key), and MCP tools use
75
+ // arbitrary names; both used to slip past a `{path: ...}` rule.
76
+ export function collectPathCandidates(tool, args) {
77
+ const a = (args ?? {});
78
+ const out = [];
79
+ for (const k of PATH_KEYS) {
80
+ if (typeof a[k] === 'string')
81
+ out.push(a[k]);
82
+ }
83
+ if (tool === 'ApplyPatch') {
84
+ // Parse the patch and pull the file headers out of it.
85
+ const text = typeof a['patch'] === 'string'
86
+ ? a['patch']
87
+ : typeof a['patch_base64'] === 'string'
88
+ ? Buffer.from(a['patch_base64'], 'base64').toString('utf-8')
89
+ : '';
90
+ const re = /^\*\*\*\s*(?:Add|Update|Delete) File:\s*(.+?)\s*$/gm;
91
+ let m;
92
+ while ((m = re.exec(text)))
93
+ out.push(m[1]);
94
+ }
95
+ // MCP tools (and any unknown tool): scan the string values for path-looking
96
+ // entries. Conservative — only values that clearly look like paths.
97
+ if (tool.includes('__')) {
98
+ for (const v of Object.values(a)) {
99
+ if (typeof v === 'string' && looksLikePath(v))
100
+ out.push(v);
101
+ }
102
+ }
103
+ return out;
104
+ }
66
105
  export function decidePermission(policy, tool, args) {
67
106
  if (!policy)
68
107
  return { action: 'allow', reason: 'no policy' };
69
108
  const a = (args ?? {});
70
109
  const command = typeof a['command'] === 'string' ? a['command'] : '';
71
- const paths = PATH_KEYS.map((k) => a[k]).filter((v) => typeof v === 'string');
110
+ const paths = collectPathCandidates(tool, args);
72
111
  for (const r of policy.rules) {
73
112
  if (r.tool) {
74
113
  const re = safeRegex(r.tool);
package/dist/run-task.js CHANGED
@@ -276,6 +276,9 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
276
276
  },
277
277
  });
278
278
  try {
279
+ // Set when the loop ends without a real answer (iteration limit / watchdog)
280
+ // so the one-shot mode can exit non-zero. See N5.
281
+ let failure = null;
279
282
  let next = {
280
283
  text: taskText,
281
284
  attachments,
@@ -363,6 +366,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
363
366
  outcome.startsWith('ask() watchdog'))) {
364
367
  ui.warning(outcome);
365
368
  transcript?.log('agent_no_answer', { outcome });
369
+ failure = outcome;
366
370
  }
367
371
  // T7: one summary line per task (duration + tool calls + tokens spent).
368
372
  // Printed even on an abort, so the operator sees what happened.
@@ -428,6 +432,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
428
432
  sendSystemPrompt: false,
429
433
  };
430
434
  }
435
+ return failure ? { ok: false, error: failure } : { ok: true };
431
436
  }
432
437
  catch (e) {
433
438
  ui.stop();
@@ -435,6 +440,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
435
440
  if (debug)
436
441
  console.error(e.stack);
437
442
  transcript?.log('agent_error', { error: e.message });
443
+ return { ok: false, error: e.message };
438
444
  }
439
445
  finally {
440
446
  stopWatching();
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ // Resolve a path against the sandbox root and reject anything that leaves it.
4
+ // The LEXICAL check (comparing the resolved path with the root) is not
5
+ // enough: a symlink inside the project can point outside it, and the
6
+ // lexical path would still look inside. Therefore the real path
7
+ // (fs.realpath, which follows links) is compared too. For a path that
8
+ // does not exist yet (writing a new file) the nearest existing ancestor
9
+ // is resolved and the remainder is appended on top.
10
+ function realPathNearest(target) {
11
+ let cur = target;
12
+ const tail = [];
13
+ while (true) {
14
+ try {
15
+ return path.join(fs.realpathSync(cur), ...tail.slice().reverse());
16
+ }
17
+ catch {
18
+ // not found (or a broken link) -> climb to the parent
19
+ }
20
+ const parent = path.dirname(cur);
21
+ if (parent === cur)
22
+ return target;
23
+ tail.push(path.basename(cur));
24
+ cur = parent;
25
+ }
26
+ }
27
+ /**
28
+ * Return the absolute path of `p` within `root`, or throw. The check is
29
+ * double: lexical (catches `../` traversal) and real (catches a symlink
30
+ * escape). Pure except for the fs.realpathSync calls, which are sync on
31
+ * purpose: the callers are sync tool handlers and turning them async would
32
+ * just add noise.
33
+ */
34
+ export function safePath(root, p) {
35
+ const resolved = path.resolve(root, p);
36
+ // startsWith(root) would let through sibling paths with a common
37
+ // prefix (C:\work\proj vs C:\work\proj-old). We compute via relative().
38
+ const rel = path.relative(root, resolved);
39
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
40
+ throw new Error(`Access outside the working directory is forbidden: ${p}`);
41
+ }
42
+ const realRoot = realPathNearest(path.resolve(root));
43
+ const realTarget = realPathNearest(resolved);
44
+ const realRel = path.relative(realRoot, realTarget);
45
+ if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
46
+ throw new Error(`Access outside the working directory is forbidden: ${p}`);
47
+ }
48
+ return resolved;
49
+ }
package/dist/scheduler.js CHANGED
@@ -100,6 +100,8 @@ export function cronMatches(expr, date) {
100
100
  // Next epoch ms at which the expression fires, strictly after `fromMs`. Scans
101
101
  // minute by minute (bounded to ~4 years); a one-time cost when a job is
102
102
  // created/rescheduled, and the field checks reject almost every minute fast.
103
+ // The parsed fields are REUSED across the scan: calling cronMatches() re-parsed
104
+ // the expression on every minute (millions of parses for a far-future date).
103
105
  export function nextCronTime(expr, fromMs) {
104
106
  const c = parseCron(expr);
105
107
  if (!c)
@@ -109,8 +111,13 @@ export function nextCronTime(expr, fromMs) {
109
111
  d.setMinutes(d.getMinutes() + 1);
110
112
  const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
111
113
  while (d.getTime() <= limit) {
112
- if (cronMatches(expr, d))
114
+ if (c.minute.has(d.getMinutes()) &&
115
+ c.hour.has(d.getHours()) &&
116
+ c.dom.has(d.getDate()) &&
117
+ c.month.has(d.getMonth() + 1) &&
118
+ c.dow.has(d.getDay())) {
113
119
  return d.getTime();
120
+ }
114
121
  d.setMinutes(d.getMinutes() + 1);
115
122
  }
116
123
  return null;
@@ -48,18 +48,40 @@ async function loadFresh() {
48
48
  async function ensureDir(p) {
49
49
  await fs.mkdir(p, { recursive: true });
50
50
  }
51
+ // Recursively list every .ts file under `dir`, as paths RELATIVE to `dir`
52
+ // (POSIX separators). self-review used to read only the top level of src/, so
53
+ // files in subfolders (src/i18n/, src/input/) never made it into a snapshot —
54
+ // the diff/apply silently ignored them. Recursing keeps the whole tree in the
55
+ // snapshot. `_context` is skipped: it is a read-only copy of configs/scripts,
56
+ // not part of the editable set.
57
+ export async function listTsFiles(dir, base = '') {
58
+ const entries = await fs.readdir(path.join(dir, base), {
59
+ withFileTypes: true,
60
+ });
61
+ const out = [];
62
+ for (const e of entries) {
63
+ const rel = base ? base + '/' + e.name : e.name;
64
+ if (e.isDirectory()) {
65
+ if (rel === '_context')
66
+ continue;
67
+ out.push(...(await listTsFiles(dir, rel)));
68
+ }
69
+ else if (e.name.endsWith('.ts')) {
70
+ out.push(rel);
71
+ }
72
+ }
73
+ return out;
74
+ }
51
75
  async function copyDirJsFiles(from, to) {
52
76
  await ensureDir(to);
53
- const entries = await fs.readdir(from);
54
- const copied = [];
55
- for (const e of entries) {
56
- if (!e.endsWith('.ts'))
57
- continue;
58
- const data = await fs.readFile(path.join(from, e), 'utf-8');
59
- await fs.writeFile(path.join(to, e), data, 'utf-8');
60
- copied.push(e);
77
+ const rels = await listTsFiles(from);
78
+ for (const rel of rels) {
79
+ const data = await fs.readFile(path.join(from, rel), 'utf-8');
80
+ const dest = path.join(to, rel);
81
+ await ensureDir(path.dirname(dest));
82
+ await fs.writeFile(dest, data, 'utf-8');
61
83
  }
62
- return copied;
84
+ return rels;
63
85
  }
64
86
  // Root files and folders a review often needs but that are NOT part of the
65
87
  // editable src/ set (scripts, lint/ts configs). They are copied into a
@@ -176,11 +198,9 @@ export async function selfDiff({ name, locale, }) {
176
198
  if (!snapStat)
177
199
  throw new Error(t('self.snapshot_not_found', { dir: snapDir }));
178
200
  const { unifiedDiff, colorDiff } = await import('./diff.js');
179
- const files = await fs.readdir(snapDir);
201
+ const files = await listTsFiles(snapDir);
180
202
  let anyDiff = false;
181
203
  for (const f of files) {
182
- if (!f.endsWith('.ts'))
183
- continue;
184
204
  const orig = await fs
185
205
  .readFile(path.join(SRC_DIR, f), 'utf-8')
186
206
  .catch(() => '');
@@ -209,7 +229,7 @@ export async function selfApply({ name, locale, }) {
209
229
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
210
230
  const backupDir = path.join(SNAP_ROOT, `backup-before-apply-${stamp}`);
211
231
  await copyDirJsFiles(SRC_DIR, backupDir);
212
- const files = await fs.readdir(snapDir);
232
+ const files = await listTsFiles(snapDir);
213
233
  const jsFiles = files.filter((f) => f.endsWith('.ts'));
214
234
  if (jsFiles.length === 0) {
215
235
  throw new Error(t('self.apply_no_ts', { dir: snapDir }));
@@ -217,7 +237,9 @@ export async function selfApply({ name, locale, }) {
217
237
  const applied = [];
218
238
  for (const f of jsFiles) {
219
239
  const data = await fs.readFile(path.join(snapDir, f), 'utf-8');
220
- await fs.writeFile(path.join(SRC_DIR, f), data, 'utf-8');
240
+ const dest = path.join(SRC_DIR, f);
241
+ await ensureDir(path.dirname(dest));
242
+ await fs.writeFile(dest, data, 'utf-8');
221
243
  applied.push(f);
222
244
  }
223
245
  console.log(theme.assistant('\n' + t('self.applied', { n: applied.length })));
@@ -265,11 +287,9 @@ export async function selfList({ locale, } = {}) {
265
287
  }
266
288
  // ---------- helpers ----------
267
289
  async function diffFiles(origDir, newDir) {
268
- const files = await fs.readdir(newDir).catch(() => []);
290
+ const files = await listTsFiles(newDir).catch(() => []);
269
291
  const changed = [];
270
292
  for (const f of files) {
271
- if (!f.endsWith('.ts'))
272
- continue;
273
293
  const a = await fs.readFile(path.join(origDir, f), 'utf-8').catch(() => '');
274
294
  const b = await fs.readFile(path.join(newDir, f), 'utf-8').catch(() => '');
275
295
  if (a !== b)
package/dist/shell.js CHANGED
@@ -1,25 +1,77 @@
1
- import { exec } from 'child_process';
1
+ import { exec, execFile } from 'child_process';
2
2
  import path from 'path';
3
+ import os from 'os';
3
4
  // Shared shell runner for the Bash tool and the operator's `!command` escape.
4
5
  // Kept in its own module so both call sites use the SAME sandbox guard and the
5
6
  // SAME abort wiring; a second, ad-hoc runner would drift from the real one.
7
+ // Quote one argument for POSIX sh: wrap in single quotes and turn every inner
8
+ // single quote into '\''. Inside single quotes sh expands NOTHING, so a value
9
+ // carrying $(...), backticks or $VAR is passed through literally. A plain
10
+ // JSON.stringify is NOT a safe substitute: it produces double quotes, inside
11
+ // which sh still performs command substitution.
12
+ export function shellQuote(arg) {
13
+ return "'" + String(arg).replace(/'/g, "'\\''") + "'";
14
+ }
6
15
  // Sandbox (option A): don't let a shell command go above the project root.
7
16
  // This is a protective barrier, not full OS isolation.
8
17
  export function assertCommandInsideRoot(root, command) {
9
18
  const cmd = String(command || '');
10
- const cdRe = /(?:^|[;&|]|\s)(?:cd|pushd)\s+([^;&|]+)/gi;
19
+ // eval re-parses its argument, so a `cd` hidden inside a string is invisible
20
+ // to the static scan; refusing eval together with cd is the honest answer
21
+ // instead of pretending the barrier still holds.
22
+ if (/\beval\b/.test(cmd) && /\bcd\b/.test(cmd)) {
23
+ throw new Error('Sandbox: eval with cd is forbidden');
24
+ }
25
+ // `(` and backticks open a subshell / command substitution where a `cd` is
26
+ // still executed; include them in the prefix so the scan sees it.
27
+ const cdRe = /(?:^|[;&|(\s`])(?:cd|pushd)\s+([^;&|)]+)/gi;
11
28
  let m;
12
29
  while ((m = cdRe.exec(cmd))) {
13
- const raw = m[1].trim();
30
+ const raw = m[1].trim().replace(/^["']|["']$/g, '');
14
31
  if (!raw || raw === '-')
15
32
  continue;
16
- const target = path.resolve(root, raw);
33
+ // sh expands ~ and $HOME AFTER this check, so expand them here as well —
34
+ // otherwise `cd ~` passes the guard and then leaves the root.
35
+ const expanded = raw
36
+ .replace(/^~(?=$|[/\\])/, os.homedir())
37
+ .replace(/\$\{?HOME\}?/g, os.homedir());
38
+ const target = path.resolve(root, expanded);
17
39
  const rel = path.relative(root, target);
18
40
  if (rel.startsWith('..') || path.isAbsolute(rel)) {
19
41
  throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
20
42
  }
21
43
  }
22
44
  }
45
+ function formatExecResult(err, stdout, stderr, timeout, okExitCodes) {
46
+ const out = (stdout || '').toString();
47
+ const errStr = (stderr || '').toString();
48
+ if (!err ||
49
+ (typeof err.code === 'number' && okExitCodes.includes(err.code))) {
50
+ const combined = (out + errStr).trim();
51
+ return combined || '(command produced no output)';
52
+ }
53
+ const parts = [];
54
+ if (err.killed) {
55
+ parts.push('\u23f1 Timeout after ' + timeout + 'ms \u2014 process killed.');
56
+ }
57
+ else if (err.code === 'ABORT_ERR') {
58
+ parts.push('(aborted by the operator)');
59
+ }
60
+ else if (err.code !== undefined && err.code !== null) {
61
+ parts.push('Exit code: ' + err.code);
62
+ }
63
+ else if (err.signal) {
64
+ parts.push('Killed by signal: ' + err.signal);
65
+ }
66
+ else {
67
+ parts.push('Error: ' + err.message);
68
+ }
69
+ if (out.trim())
70
+ parts.push('stdout:\n' + out.trim());
71
+ if (errStr.trim())
72
+ parts.push('stderr:\n' + errStr.trim());
73
+ return parts.join('\n');
74
+ }
23
75
  export function runShell(workdir, command, timeout = 30_000, signal) {
24
76
  return new Promise((resolve) => {
25
77
  const options = {
@@ -39,34 +91,24 @@ export function runShell(workdir, command, timeout = 30_000, signal) {
39
91
  options.shell = '/bin/sh';
40
92
  }
41
93
  exec(command, options, (err, stdout, stderr) => {
42
- const out = (stdout || '').toString();
43
- const errStr = (stderr || '').toString();
44
- if (!err) {
45
- const combined = (out + errStr).trim();
46
- resolve(combined || '(command produced no output)');
47
- return;
48
- }
49
- const parts = [];
50
- if (err.killed) {
51
- parts.push('\u23f1 Timeout after ' + timeout + 'ms \u2014 process killed.');
52
- }
53
- else if (err.code === 'ABORT_ERR') {
54
- parts.push('(aborted by the operator)');
55
- }
56
- else if (err.code !== undefined && err.code !== null) {
57
- parts.push('Exit code: ' + err.code);
58
- }
59
- else if (err.signal) {
60
- parts.push('Killed by signal: ' + err.signal);
61
- }
62
- else {
63
- parts.push('Error: ' + err.message);
64
- }
65
- if (out.trim())
66
- parts.push('stdout:\n' + out.trim());
67
- if (errStr.trim())
68
- parts.push('stderr:\n' + errStr.trim());
69
- resolve(parts.join('\n'));
94
+ resolve(formatExecResult(err, stdout, stderr, timeout, []));
95
+ });
96
+ });
97
+ }
98
+ // Run an executable directly with an argument ARRAY (no shell). Use this
99
+ // whenever an argument comes from the model: the shell never sees it, so
100
+ // $(...), backticks and quotes cannot be interpreted as commands.
101
+ export function runFile(workdir, file, args, timeout = 30_000, signal, okExitCodes = []) {
102
+ return new Promise((resolve) => {
103
+ execFile(file, args, {
104
+ cwd: workdir,
105
+ timeout,
106
+ maxBuffer: 1024 * 1024 * 8,
107
+ windowsHide: true,
108
+ env: { ...process.env },
109
+ signal,
110
+ }, (err, stdout, stderr) => {
111
+ resolve(formatExecResult(err, stdout, stderr, timeout, okExitCodes));
70
112
  });
71
113
  });
72
114
  }
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import { assertCommandInsideRoot, runShell } from './shell.js';
3
+ import { assertCommandInsideRoot, runFile, runShell } from './shell.js';
4
+ import { safePath } from './sandbox.js';
4
5
  import { createGitTools } from './gitTools.js';
5
6
  import { createWebTools } from './web.js';
6
7
  import { createExtraTools } from './extraTools.js';
@@ -10,16 +11,7 @@ import { createExtraTools } from './extraTools.js';
10
11
  // those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
11
12
  export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
12
13
  const root = path.resolve(workdir);
13
- const safe = (p) => {
14
- const resolved = path.resolve(root, p);
15
- // startsWith(root) would let through sibling paths with a common prefix
16
- // (C:\work\proj vs C:\work\proj-old). We compute via relative().
17
- const rel = path.relative(root, resolved);
18
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
19
- throw new Error(`Access outside the working directory is forbidden: ${p}`);
20
- }
21
- return resolved;
22
- };
14
+ const safe = (p) => safePath(root, p);
23
15
  // Extracts text from content/content_base64. base64 is needed because the
24
16
  // channel that delivers the model's answer may corrupt characters ($,
25
17
  // backslashes, newlines). base64 consists only of [A-Za-z0-9+/=] and is not
@@ -124,7 +116,9 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
124
116
  }
125
117
  if (undo)
126
118
  await undo.backup(file);
127
- content = content.replace(oldStr, newStr);
119
+ // Functional replacement: a string replacer interprets $-patterns
120
+ // ($&, $$, $1) inside newStr and silently corrupts the written text.
121
+ content = content.replace(oldStr, () => newStr);
128
122
  await fs.writeFile(file, content, 'utf-8');
129
123
  return `Edited: ${p}`;
130
124
  },
@@ -182,17 +176,18 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
182
176
  .map((s) => s.trim())
183
177
  .filter(Boolean);
184
178
  const mode = String(output || 'content').toLowerCase();
179
+ // The pattern and the target come from the model, so they are passed
180
+ // as an execFile ARGUMENT ARRAY: the shell never parses them and a
181
+ // pattern carrying $(...)/backticks cannot run as a command (this
182
+ // used to be a sandbox escape). Exit code 1 = no matches, not an error.
185
183
  if (process.platform === 'win32') {
186
- const escaped = pat.replace(/"/g, '\\"');
187
- const scope = searchPath ? '"' + target + '\\*' : '*';
188
- return runShell(workdir, `findstr /s /n /r /c:"${escaped}" ` + scope);
184
+ const args = ['/s', '/n', '/r', '/c:' + pat];
185
+ args.push(searchPath ? target + '\\*' : '*');
186
+ return runFile(workdir, 'findstr', args, 30_000, undefined, [1]);
189
187
  }
190
188
  const flags = mode === 'files_only' ? '-rlE' : mode === 'count' ? '-rcE' : '-rnE';
191
- const includeArg = incs
192
- .map((g) => ' --include=' + JSON.stringify(g))
193
- .join('');
194
- return runShell(workdir, `grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
195
- `${JSON.stringify(target)} || true`);
189
+ const args = [flags, ...incs.map((g) => '--include=' + g), pat, target];
190
+ return runFile(workdir, 'grep', args, 30_000, undefined, [1]);
196
191
  },
197
192
  },
198
193
  ];
@@ -233,3 +228,28 @@ export const MUTATING_TOOLS = new Set([
233
228
  export function filterToolsForReadOnly(tools) {
234
229
  return tools.filter((t) => !MUTATING_TOOLS.has(t.name));
235
230
  }
231
+ // MCP tool names are opaque (server + '__' + tool), so the exact-name
232
+ // MUTATING_TOOLS set cannot classify them. Instead we match the common
233
+ // mutating VERBS in the tool name. It is a heuristic deny-list, not a proof:
234
+ // an exotic server could name a mutating tool innocuously. The honest default
235
+ // in read-only mode is therefore to drop everything that looks like a writer.
236
+ const MCP_MUTATING_RE = /(^|_)(click|type|navigate|press|upload|select|drag|fill|hover|evaluate|run_code|run_|handle_dialog|key|screenshot|write|create|delete|update|set|put|post|patch|execute|exec|insert|remove|drop|restart|stop|start|install|kill|move|rename|copy|mkdir|rmdir|touch|chmod|chown)(_|$)/;
237
+ export function isMutatingMcpTool(name) {
238
+ // Strip the server prefix and normalize separators to underscores so the
239
+ // verb matcher sees "browser_click" -> "click".
240
+ const tail = String(name).split('__').pop() || String(name);
241
+ const norm = tail.toLowerCase().replace(/[^a-z0-9]+/g, '_');
242
+ return MCP_MUTATING_RE.test('_' + norm + '_');
243
+ }
244
+ // Merge MCP tools into the base set. In read-only (plan) mode the MCP tools
245
+ // must be filtered too: an MCP server (e.g. @playwright/mcp) exposes mutating
246
+ // tools (click/type/navigate) that would otherwise defeat the read-only
247
+ // promise. This is a SEPARATE helper (not ad-hoc push sites) so the filtering
248
+ // cannot be forgotten at one of the four call sites. Pure; unit-tested.
249
+ export function mergeTools(base, mcp, readOnly = false) {
250
+ const all = mcp || [];
251
+ const extra = readOnly
252
+ ? all.filter((t) => !MUTATING_TOOLS.has(t.name) && !isMutatingMcpTool(t.name))
253
+ : all;
254
+ return [...base, ...extra];
255
+ }
@@ -53,8 +53,21 @@ export class Transcript {
53
53
  }
54
54
  catch { }
55
55
  }
56
+ // Flush the stream and resolve once the data reached the file. The callers
57
+ // run on process.exit paths (one-shot --task, browser error, clean exit), and
58
+ // end() without awaiting the callback lets the process die before the buffer
59
+ // is written — exactly the final events (task result, agent error) one needs
60
+ // for a post-mortem. A no-op promise when there is no stream.
56
61
  close() {
57
- if (this.stream)
58
- this.stream.end();
62
+ const stream = this.stream;
63
+ this.stream = null;
64
+ if (!stream)
65
+ return Promise.resolve();
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.
70
+ stream.on('error', () => resolve());
71
+ });
59
72
  }
60
73
  }