arena-bridge 0.6.7 → 0.6.9

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
@@ -4,6 +4,25 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.6.9
8
+
9
+ ### Fixed
10
+ - Desktop control survives a Peekaboo update: when the Peekaboo Bridge host loses its macOS
11
+ permissions (Screen Recording / Accessibility), the bridge falls back to the local Peekaboo
12
+ runtime and keeps working. If nothing can capture, the tool now says so instead of quietly
13
+ returning an empty element list.
14
+
15
+ ## 0.6.8
16
+
17
+ ### Added
18
+ - Detailed work log: every call is recorded with its arguments, outcome and duration, and the
19
+ evidence is kept next to the log — screenshots taken on the desktop, the accessibility trees
20
+ that were read, menu trees and the list of Spaces.
21
+ - `audit_log` / `audit_artifact` tools and `GET /audit`, `GET /audit/artifact` endpoints let the
22
+ agent look back at what it saw.
23
+ - `arena-bridge audit` lists the calls, `arena-bridge audit artifacts` lists the saved files,
24
+ `arena-bridge audit <id>` prints one entry, `arena-bridge audit prune` removes old artifacts.
25
+
7
26
  ## 0.6.7
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="license">
13
13
  <img src="https://img.shields.io/badge/node-%3E%3D18-green.svg?style=flat-square" alt="node">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen.svg?style=flat-square" alt="dependencies">
15
- <img src="https://img.shields.io/badge/tests-115%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-124%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -95,6 +95,7 @@ arena-bridge setup --root ~/projects
95
95
  | Группа | Инструменты | Что это |
96
96
  | --- | --- | --- |
97
97
  | Файлы | `list_dir` `read_file` `write_file` `edit_file` `delete_path` `search_files` `file_get` `file_put` | работа с файлами в разрешённых папках |
98
+ | Журнал | `audit_log` `audit_artifact` | что делалось, какие снимки и деревья Accessibility сохранены |
98
99
  | Шелл | `bash` `sysinfo` | команды и сведения о машине |
99
100
  | Процессы | `start_process` `read_process_output` `write_process` `kill_process` | фоновые дев-серверы |
100
101
  | Chrome | `chrome_launch` `chrome_profiles` `chrome_attach` `chrome_open` `chrome_navigate` `chrome_snapshot` `chrome_click` `chrome_type` `chrome_press` `chrome_screenshot` `chrome_evaluate` `chrome_tabs` `chrome_stop` | браузер через CDP |
@@ -199,6 +200,25 @@ macOS отдаёт приложениям два разных слоя: карт
199
200
 
200
201
  ---
201
202
 
203
+ ## Журнал работы с артефактами
204
+
205
+ Каждый вызов записывается: аргументы, результат, время выполнения. Рядом с журналом хранятся
206
+ **доказательства** — снимки экрана, деревья Accessibility, деревья меню, список рабочих столов.
207
+
208
+ ```bash
209
+ arena-bridge audit # последние вызовы
210
+ arena-bridge audit artifacts # сохранённые файлы (скриншоты, деревья)
211
+ arena-bridge audit <id> # одна запись целиком
212
+ arena-bridge audit prune # удалить старые артефакты (по умолчанию старше 14 дней)
213
+ arena-bridge audit dir # где всё это лежит
214
+ ```
215
+
216
+ Агенту доступно то же самое: `audit_log` (вызовы) и `audit_artifact` (сам артефакт — PNG в base64
217
+ или дерево Accessibility в JSON), а также `GET /audit` и `GET /audit/artifact?entry=<id>&file=<name>`.
218
+ Полезно, чтобы оглянуться назад: что было на экране и что находилось в интерфейсе.
219
+
220
+ ---
221
+
202
222
  ## Доверенное окно: работать без подтверждений
203
223
 
204
224
  ```bash
@@ -309,6 +329,8 @@ GET /health liveness (без авторизации)
309
329
  GET /tools каталог инструментов с JSON-схемами
310
330
  GET /prompt этот промпт для агента
311
331
  GET /approvals ожидающие подтверждения
332
+ GET /audit журнал работы с артефактами
333
+ GET /audit/artifact сохранённый артефакт (entry=, file=)
312
334
  POST /call {"tool":"bash","args":{...}}
313
335
  POST /exec {"command":"...","cwd":"...","timeout_sec":120}
314
336
  GET|PUT|DELETE /files?path=/abs/path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "Give a cloud AI agent (Arena.ai Agent Mode, Claude, Cursor, ...) controlled access to your own machine over HTTP/MCP: files, shell, Chrome and the macOS desktop, with an approval gate, trust windows and an audit log.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/audit.mjs CHANGED
@@ -1,20 +1,31 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { CONFIG_DIR, AUDIT_PATH } from './config.mjs';
3
+ import { CONFIG_DIR } from './config.mjs';
4
+
5
+ /** The config directory is resolved on every call so tests and relocations stay isolated. */
6
+ function configDir() {
7
+ return process.env.ARENA_BRIDGE_HOME || CONFIG_DIR;
8
+ }
9
+
10
+ export function auditPath() {
11
+ return path.join(configDir(), 'audit.log');
12
+ }
4
13
 
5
14
  export function audit(entry) {
6
15
  try {
7
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
16
+ const dir = configDir();
17
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
8
18
  const rec = { ts: new Date().toISOString(), ...entry };
9
- fs.appendFileSync(AUDIT_PATH, JSON.stringify(rec) + '\n', { mode: 0o600 });
19
+ fs.appendFileSync(auditPath(), JSON.stringify(rec) + '\n', { mode: 0o600 });
10
20
  } catch {
11
21
  /* auditing must never break the request */
12
22
  }
13
23
  }
14
24
 
15
25
  export function tailAudit(n = 50) {
16
- if (!fs.existsSync(AUDIT_PATH)) return [];
17
- const lines = fs.readFileSync(AUDIT_PATH, 'utf8').trim().split('\n').filter(Boolean);
26
+ const file = auditPath();
27
+ if (!fs.existsSync(file)) return [];
28
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
18
29
  return lines
19
30
  .slice(-n)
20
31
  .map((l) => {
@@ -25,3 +36,76 @@ export function tailAudit(n = 50) {
25
36
  }
26
37
  });
27
38
  }
39
+
40
+ /* ------------------------------------------------------------------ *
41
+ * artifacts: screenshots, accessibility trees and other evidence
42
+ * ------------------------------------------------------------------ */
43
+
44
+ export function auditDir() {
45
+ return path.join(configDir(), 'audit');
46
+ }
47
+
48
+ export function saveArtifact(entryId, kind, name, data, meta = {}) {
49
+ try {
50
+ const dir = path.join(auditDir(), String(entryId));
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ const file = path.join(dir, path.basename(String(name)));
53
+ fs.writeFileSync(file, data, { mode: 0o600 });
54
+ const bytes = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data));
55
+ return { kind, name: path.basename(String(name)), path: file, bytes, ...meta };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function readArtifact(entryId, name) {
62
+ try {
63
+ const file = path.join(auditDir(), String(entryId), path.basename(String(name)));
64
+ if (!fs.existsSync(file)) return null;
65
+ return { file, data: fs.readFileSync(file) };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function listArtifacts() {
72
+ const dir = auditDir();
73
+ if (!fs.existsSync(dir)) return [];
74
+ const out = [];
75
+ for (const entry of fs.readdirSync(dir)) {
76
+ const sub = path.join(dir, entry);
77
+ let files = [];
78
+ try {
79
+ files = fs
80
+ .readdirSync(sub)
81
+ .filter((f) => fs.statSync(path.join(sub, f)).isFile())
82
+ .map((f) => ({ name: f, bytes: fs.statSync(path.join(sub, f)).size }));
83
+ } catch {
84
+ /* ignore */
85
+ }
86
+ if (files.length) out.push({ entry, files });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ export function pruneArtifacts(days = 14) {
92
+ const dir = auditDir();
93
+ if (!fs.existsSync(dir)) return { removed: 0, freed_bytes: 0 };
94
+ const cutoff = Date.now() - Number(days) * 86400000;
95
+ let removed = 0;
96
+ let freed = 0;
97
+ for (const entry of fs.readdirSync(dir)) {
98
+ const sub = path.join(dir, entry);
99
+ try {
100
+ const st = fs.statSync(sub);
101
+ if (st.mtimeMs < cutoff) {
102
+ freed += st.size;
103
+ fs.rmSync(sub, { recursive: true, force: true });
104
+ removed++;
105
+ }
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ }
110
+ return { removed, freed_bytes: freed };
111
+ }
package/src/browser.mjs CHANGED
@@ -652,6 +652,9 @@ export class BrowserManager {
652
652
  }
653
653
  const { data } = await session.cdp.send('Page.captureScreenshot', params, tab.sessionId);
654
654
  const b64 = String(data || '');
655
+ if (ctx.artifact && b64) {
656
+ ctx.artifact('screenshot', 'page.png', Buffer.from(b64, 'base64'), { source: 'chrome_screenshot' });
657
+ }
655
658
  if (args.path) {
656
659
  const chk = checkPath(args.path, ctx.cfg, ctx.base, { write: true });
657
660
  if (!chk.ok) return { ok: false, error: chk.error, reason: chk.reason };
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { createHubServer } from './hub.mjs';
9
9
  import { runDevice } from './device.mjs';
10
10
  import { buildPrompt } from './prompt.mjs';
11
11
  import { listApprovals, decide } from './approvals.mjs';
12
- import { tailAudit } from './audit.mjs';
12
+ import { tailAudit, auditDir, listArtifacts, pruneArtifacts } from './audit.mjs';
13
13
  import { startTunnel, isPortOpen } from './tunnel.mjs';
14
14
  import { spawn, execSync } from 'node:child_process';
15
15
  import { parseDuration, trustState, saveTrust, clearTrust } from './trust.mjs';
@@ -377,10 +377,33 @@ function cmdTrust(args) {
377
377
  }
378
378
 
379
379
  function cmdAudit(args) {
380
+ const what = args._[1];
381
+ if (what === 'dir') {
382
+ console.log(auditDir());
383
+ return;
384
+ }
385
+ if (what === 'prune') {
386
+ const res = pruneArtifacts(Number(args.flags.days || 14));
387
+ console.log(`removed ${res.removed} artifact folder(s), ${res.freed_bytes} bytes`);
388
+ return;
389
+ }
390
+ if (what === 'artifacts') {
391
+ for (const item of listArtifacts()) {
392
+ const files = item.files.map((f) => `${f.name} (${f.bytes} B)`).join(', ');
393
+ console.log(`${item.entry} ${files}`);
394
+ }
395
+ return;
396
+ }
397
+ if (what && what.length > 6) {
398
+ const rec = tailAudit(500).find((e) => e.id === what);
399
+ console.log(rec ? JSON.stringify(rec, null, 2) : `no entry with id ${what}`);
400
+ return;
401
+ }
380
402
  const n = Number(args.flags.n || 30);
381
403
  for (const rec of tailAudit(n)) {
404
+ const arts = (rec.artifacts || []).map((a) => a.name).join(' ');
382
405
  console.log(
383
- `${rec.ts} ${String(rec.tool).padEnd(18)} ${String(rec.status).padEnd(16)} ${rec.duration_ms ?? ''}${
406
+ `${rec.ts} ${String(rec.tool).padEnd(18)} ${String(rec.status).padEnd(14)} ${String(rec.duration_ms ?? '').padStart(6)}ms${arts ? ' ' + arts : ''}${
384
407
  rec.error ? ' ' + rec.error : ''
385
408
  }`,
386
409
  );
package/src/direct.mjs CHANGED
@@ -6,6 +6,7 @@ import { buildPrompt } from './prompt.mjs';
6
6
  import { listApprovals, decide } from './approvals.mjs';
7
7
  import { checkPath } from './policy.mjs';
8
8
  import { loadConfig } from './config.mjs';
9
+ import { tailAudit, readArtifact, auditDir } from './audit.mjs';
9
10
 
10
11
  export function createDirectServer(cfg, engine) {
11
12
  const mcpCtx = {
@@ -29,7 +30,7 @@ export function createDirectServer(cfg, engine) {
29
30
  ok: true,
30
31
  mode: 'direct',
31
32
  device: cfg.device,
32
- version: '0.6.7',
33
+ version: '0.6.9',
33
34
  uptime_sec: Math.round(process.uptime()),
34
35
  });
35
36
  }
@@ -62,6 +63,29 @@ export function createDirectServer(cfg, engine) {
62
63
  });
63
64
  }
64
65
 
66
+ if (req.method === 'GET' && p === '/audit') {
67
+ const limit = Math.min(200, Number(url.searchParams.get('limit')) || 30);
68
+ return sendJson(res, 200, { ok: true, entries: tailAudit(limit), artifacts_dir: auditDir() });
69
+ }
70
+
71
+ if (req.method === 'GET' && p === '/audit/artifact') {
72
+ const entry = url.searchParams.get('entry');
73
+ const file = url.searchParams.get('file');
74
+ if (!entry || !file) {
75
+ return sendJson(res, 400, { ok: false, error: 'entry and file query parameters are required' });
76
+ }
77
+ const art = readArtifact(entry, file);
78
+ if (!art) return sendJson(res, 404, { ok: false, error: 'artifact_not_found' });
79
+ const type = /\.png$/i.test(art.file)
80
+ ? 'image/png'
81
+ : /\.jpe?g$/i.test(art.file)
82
+ ? 'image/jpeg'
83
+ : /\.json$/i.test(art.file)
84
+ ? 'application/json; charset=utf-8'
85
+ : 'text/plain; charset=utf-8';
86
+ return sendRaw(res, 200, art.data, type);
87
+ }
88
+
65
89
  if (req.method === 'GET' && p === '/approvals') {
66
90
  return sendJson(res, 200, { ok: true, approvals: listApprovals() });
67
91
  }
package/src/engine.mjs CHANGED
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { checkPath, resolveTarget, isInside } from './policy.mjs';
7
- import { audit } from './audit.mjs';
7
+ import { audit, saveArtifact, tailAudit, readArtifact, auditDir } from './audit.mjs';
8
8
  import { requestApproval } from './approvals.mjs';
9
9
  import { loadConfig, CONFIG_PATH } from './config.mjs';
10
10
  import { BrowserManager } from './browser.mjs';
@@ -393,6 +393,63 @@ export function toolCatalog() {
393
393
  return { ok: true, path: chk.abs, bytes: data.length };
394
394
  },
395
395
  },
396
+ {
397
+ name: 'audit_log',
398
+ description:
399
+ 'Read the detailed log of what happened on this machine: every tool call with its arguments, outcome and saved artifacts (screenshots, accessibility trees). Read-only.',
400
+ approval: false,
401
+ schema: S(
402
+ {
403
+ limit: { type: 'number', description: 'how many entries to return, default 30' },
404
+ tool: { type: 'string', description: 'only entries of this tool' },
405
+ since: { type: 'string', description: 'only entries newer than this ISO timestamp' },
406
+ },
407
+ [],
408
+ ),
409
+ required: [],
410
+ handler: async (args) => {
411
+ const n = Math.min(200, Number(args.limit) || 30);
412
+ let entries = tailAudit(n * 4);
413
+ if (args.tool) entries = entries.filter((e) => e.tool === args.tool);
414
+ if (args.since) {
415
+ const since = Date.parse(String(args.since));
416
+ if (!Number.isNaN(since)) entries = entries.filter((e) => Date.parse(e.ts) >= since);
417
+ }
418
+ entries = entries.slice(-n);
419
+ return { ok: true, count: entries.length, entries, artifacts_dir: auditDir() };
420
+ },
421
+ },
422
+ {
423
+ name: 'audit_artifact',
424
+ description:
425
+ 'Fetch a saved artifact of a logged call: a screenshot (PNG as base64) or an accessibility tree (JSON). Read-only.',
426
+ approval: false,
427
+ schema: S(
428
+ {
429
+ entry: { type: 'string', description: 'entry id from audit_log' },
430
+ file: { type: 'string', description: 'artifact file name, e.g. "screen.png" or "ax-tree.json"' },
431
+ },
432
+ ['entry', 'file'],
433
+ ),
434
+ required: ['entry', 'file'],
435
+ handler: async (args) => {
436
+ const art = readArtifact(args.entry, args.file);
437
+ if (!art) {
438
+ return { ok: false, error: 'artifact_not_found', hint: 'Run audit_log to see the available artifacts.' };
439
+ }
440
+ if (/\.(png|jpe?g)$/i.test(art.file)) {
441
+ return { ok: true, kind: 'image', file: art.file, bytes: art.data.length, image_base64: art.data.toString('base64') };
442
+ }
443
+ const text = art.data.toString('utf8');
444
+ let json = null;
445
+ try {
446
+ json = JSON.parse(text);
447
+ } catch {
448
+ /* not json */
449
+ }
450
+ return { ok: true, kind: json ? 'json' : 'text', file: art.file, bytes: art.data.length, content: json ?? text.slice(0, 200000) };
451
+ },
452
+ },
396
453
  {
397
454
  name: 'sysinfo',
398
455
  description: 'Read-only information about the remote machine: OS, user, uptime, memory, disks, network interfaces.',
@@ -581,6 +638,17 @@ export class Engine {
581
638
  if (!tool) return { ok: false, error: `unknown tool: ${name}` };
582
639
  const started = Date.now();
583
640
  let approval = null;
641
+ const entryId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(name).replace(/[^\w.-]/g, '_')}-${Math.random()
642
+ .toString(36)
643
+ .slice(2, 8)}`;
644
+ const ctx = Object.create(this);
645
+ ctx.entryId = entryId;
646
+ ctx.artifacts = [];
647
+ ctx.artifact = (kind, file, data, meta) => {
648
+ const saved = saveArtifact(entryId, kind, file, data, meta);
649
+ if (saved) ctx.artifacts.push(saved);
650
+ return saved;
651
+ };
584
652
 
585
653
  // pick up policy changes (roots, protected, requireApproval) without a restart
586
654
  try {
@@ -598,16 +666,16 @@ export class Engine {
598
666
  const trust = trustState();
599
667
  if (trustAllows(trust, tool.name, args, this.cfg)) {
600
668
  if (this.cfg.audit !== false) {
601
- audit({ tool: tool.name, args, status: 'ok', via: 'trust_window' });
669
+ audit({ id: entryId, tool: tool.name, args, status: 'ok', via: 'trust_window' });
602
670
  }
603
- const res = await tool.handler(args, this);
671
+ const res = await tool.handler(args, ctx);
604
672
  return res;
605
673
  }
606
674
  approval = await requestApproval(tool.name, args, this.cfg);
607
675
  if (!approval.approved) {
608
676
  const status = approval.rec?.status || 'denied';
609
677
  if (this.cfg.audit !== false) {
610
- audit({ tool: tool.name, args, status: 'approval_denied', approval_status: status });
678
+ audit({ id: entryId, tool: tool.name, args, status: 'approval_denied', approval_status: status });
611
679
  }
612
680
  const pending = status === 'pending' || status === 'timeout' || status === 'expired';
613
681
  return {
@@ -627,21 +695,31 @@ export class Engine {
627
695
  }
628
696
 
629
697
  try {
630
- const res = await tool.handler(args, this);
698
+ const res = await tool.handler(args, ctx);
631
699
  if (this.cfg.audit !== false) {
632
700
  audit({
701
+ id: entryId,
633
702
  tool: tool.name,
634
703
  args,
635
704
  status: res.ok ? 'ok' : 'error',
636
705
  duration_ms: Date.now() - started,
637
706
  exit_code: res.exit_code,
638
707
  error: res.ok ? undefined : res.error,
708
+ artifacts: ctx.artifacts.length ? ctx.artifacts : undefined,
639
709
  });
640
710
  }
641
711
  return res;
642
712
  } catch (e) {
643
713
  if (this.cfg.audit !== false) {
644
- audit({ tool: tool.name, args, status: 'exception', duration_ms: Date.now() - started, error: e.message });
714
+ audit({
715
+ id: entryId,
716
+ tool: tool.name,
717
+ args,
718
+ status: 'exception',
719
+ duration_ms: Date.now() - started,
720
+ error: e.message,
721
+ artifacts: ctx.artifacts.length ? ctx.artifacts : undefined,
722
+ });
645
723
  }
646
724
  return { ok: false, error: e.message };
647
725
  }
package/src/hub.mjs CHANGED
@@ -90,7 +90,7 @@ export function createHubServer(cfg) {
90
90
  return sendJson(res, 200, {
91
91
  ok: true,
92
92
  mode: 'hub',
93
- version: '0.6.7',
93
+ version: '0.6.9',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
package/src/mac.mjs CHANGED
@@ -193,6 +193,7 @@ export function macTools(cfg = {}) {
193
193
  ['see', '--tree', '--no-screenshot', '--json', ...targetFlags(args)],
194
194
  ['see', '--tree', '--no-screenshot', '--no-remote', '--json', ...targetFlags(args)],
195
195
  ['see', '--tree', '--json', ...targetFlags(args)],
196
+ ['see', '--no-remote', '--tree', '--json', ...targetFlags(args)],
196
197
  ['see', '--json', ...targetFlags(args)],
197
198
  ];
198
199
  }
@@ -213,7 +214,8 @@ export function macTools(cfg = {}) {
213
214
  if (!best) best = cand;
214
215
  if (res.exit_code === 0 && cand.elements.length) return cand;
215
216
  if (res.exit_code === 0 && best.res.exit_code !== 0) best = cand;
216
- if (res.exit_code !== 0 && !UNKNOWN.test(res.stderr || '')) break;
217
+ const denied = isPermissionError(json, res);
218
+ if (res.exit_code !== 0 && !denied && !UNKNOWN.test(res.stderr || '')) break;
217
219
  }
218
220
  return best;
219
221
  };
@@ -259,6 +261,15 @@ export function macTools(cfg = {}) {
259
261
  if (!outPath.ok) return { ok: false, error: outPath.error, reason: outPath.reason };
260
262
  ensureDir(path.dirname(outPath.abs));
261
263
  const seen = await collectSee(args, outPath.abs);
264
+ const failure = peekError(seen.json);
265
+ if (failure && !seen.elements.length) {
266
+ return {
267
+ ok: false,
268
+ error: 'peekaboo_failed',
269
+ reason: failure.message || String(failure.code || 'peekaboo returned an error'),
270
+ hint: PERMISSION_HINT,
271
+ };
272
+ }
262
273
  const elements = seen.elements.slice(0, 300);
263
274
  const saved = fs.existsSync(outPath.abs);
264
275
  if (!saved) {
@@ -284,6 +295,21 @@ export function macTools(cfg = {}) {
284
295
  out.hint += ' No elements found — run mac_permissions: the element list comes from the macOS Accessibility API.';
285
296
  }
286
297
  if (args.include_raw && seen.json) out.raw = seen.json;
298
+ if (ctx.artifact) {
299
+ try {
300
+ if (fs.existsSync(outPath.abs)) {
301
+ ctx.artifact('screenshot', 'screen.png', fs.readFileSync(outPath.abs), { source: 'mac_see' });
302
+ }
303
+ if (seen.json) {
304
+ ctx.artifact('ax-tree', 'ax-tree.json', JSON.stringify(seen.json, null, 2), {
305
+ source: 'mac_see',
306
+ elements: seen.elements.length,
307
+ });
308
+ }
309
+ } catch {
310
+ /* artifacts are best effort */
311
+ }
312
+ }
287
313
  return out;
288
314
  },
289
315
  },
@@ -593,11 +619,18 @@ export function macTools(cfg = {}) {
593
619
  [],
594
620
  ),
595
621
  required: [],
596
- handler: async (args) => {
622
+ handler: async (args, ctx) => {
597
623
  const p = checkPeek(peek());
598
624
  if (!p.ok) return p;
599
625
  const seen = await searchSee(args);
600
626
  const matches = seen.elements.filter((e) => matchElement(e, args)).slice(0, Number(args.limit) || 25);
627
+ if (ctx.artifact && seen.json) {
628
+ ctx.artifact('ax-tree', 'ax-tree.json', JSON.stringify(seen.json, null, 2), {
629
+ source: 'mac_element',
630
+ elements: seen.elements.length,
631
+ query: args.name || args.role || null,
632
+ });
633
+ }
601
634
  return {
602
635
  ok: true,
603
636
  matches,
@@ -645,7 +678,7 @@ export function macTools(cfg = {}) {
645
678
  approval: false,
646
679
  schema: S({ app: { type: 'string' } }, []),
647
680
  required: [],
648
- handler: async (args) => {
681
+ handler: async (args, ctx) => {
649
682
  const p = checkPeek(peek());
650
683
  if (!p.ok) return p;
651
684
  const flags = menuFlags(args);
@@ -659,6 +692,9 @@ export function macTools(cfg = {}) {
659
692
  { timeout: 30 },
660
693
  );
661
694
  const json = parseJson(res.stdout);
695
+ if (ctx.artifact && json) {
696
+ ctx.artifact('menu-tree', 'menu-tree.json', JSON.stringify(json, null, 2), { source: 'mac_menu' });
697
+ }
662
698
  return {
663
699
  ok: res.exit_code === 0,
664
700
  menus: json ? (json.menus || json.data?.menus || json.data?.menu_structure || json) : undefined,
@@ -716,7 +752,7 @@ export function macTools(cfg = {}) {
716
752
  approval: false,
717
753
  schema: S({}, []),
718
754
  required: [],
719
- handler: async () => {
755
+ handler: async (args, ctx) => {
720
756
  const p = checkPeek(peek());
721
757
  if (!p.ok) return p;
722
758
  const res = await runVariants(
@@ -729,6 +765,9 @@ export function macTools(cfg = {}) {
729
765
  { timeout: 30 },
730
766
  );
731
767
  const json = parseJson(res.stdout);
768
+ if (ctx.artifact && json) {
769
+ ctx.artifact('spaces', 'spaces.json', JSON.stringify(json, null, 2), { source: 'mac_spaces' });
770
+ }
732
771
  return {
733
772
  ok: res.exit_code === 0,
734
773
  spaces: json ? (json.spaces || json.data?.spaces || json) : undefined,
@@ -744,7 +783,7 @@ export function macTools(cfg = {}) {
744
783
  approval: true,
745
784
  schema: S({ to: { type: 'string', description: 'space id or index from mac_spaces' } }, ['to']),
746
785
  required: ['to'],
747
- handler: async (args) => {
786
+ handler: async (args, ctx) => {
748
787
  const p = checkPeek(peek());
749
788
  if (!p.ok) return p;
750
789
  const res = await runVariants(
@@ -775,14 +814,16 @@ export function macTools(cfg = {}) {
775
814
  [],
776
815
  ),
777
816
  required: [],
778
- handler: async (args) => {
817
+ handler: async (args, ctx) => {
779
818
  const p = checkPeek(peek());
780
819
  if (!p.ok) return p;
781
820
  const limit = Math.min(120, Math.max(1, Number(args.timeout) || 20));
782
821
  const deadline = Date.now() + limit * 1000;
783
822
  let scanned = 0;
823
+ let last = null;
784
824
  while (Date.now() < deadline) {
785
825
  const seen = await searchSee(args);
826
+ last = seen;
786
827
  scanned = seen.elements.length;
787
828
  const hit = seen.elements.find((e) => matchElement(e, args));
788
829
  if (args.disappear) {
@@ -792,6 +833,12 @@ export function macTools(cfg = {}) {
792
833
  }
793
834
  await new Promise((r) => setTimeout(r, 2000));
794
835
  }
836
+ if (ctx.artifact && last?.json) {
837
+ ctx.artifact('ax-tree', 'ax-tree.json', JSON.stringify(last.json, null, 2), {
838
+ source: 'mac_wait',
839
+ elements: scanned,
840
+ });
841
+ }
795
842
  return {
796
843
  ok: false,
797
844
  found: false,
@@ -808,7 +855,7 @@ export function macTools(cfg = {}) {
808
855
  approval: false,
809
856
  schema: S({}, []),
810
857
  required: [],
811
- handler: async () => {
858
+ handler: async (args, ctx) => {
812
859
  const p = checkPeek(peek());
813
860
  if (!p.ok) {
814
861
  return {
@@ -831,6 +878,25 @@ export function macTools(cfg = {}) {
831
878
  ];
832
879
  }
833
880
 
881
+ /** The Peekaboo envelope reports a host that lost its macOS permissions. */
882
+ function peekError(json) {
883
+ if (!json || typeof json !== 'object') return null;
884
+ const err = json.error;
885
+ if (err && typeof err === 'object' && (err.code || err.message)) return err;
886
+ return null;
887
+ }
888
+
889
+ function isPermissionError(json, res) {
890
+ const err = peekError(json);
891
+ const text = `${err?.code || ''} ${err?.message || ''} ${res?.stderr || ''}`;
892
+ return /permission|screen recording|accessibility|event synthesizing/i.test(text);
893
+ }
894
+
895
+ const PERMISSION_HINT =
896
+ 'The Peekaboo host that captures the screen lost its macOS permissions (this happens after a Peekaboo update). ' +
897
+ 'Re-grant Screen Recording and Accessibility to "Peekaboo Bridge" in System Settings → Privacy & Security, ' +
898
+ 'or let the bridge use the local runtime (--no-remote). Run mac_permissions for the current state.';
899
+
834
900
  function menuFlags(args) {
835
901
  const f = [];
836
902
  if (args.app) f.push('--app', String(args.app));
package/src/mcp.mjs CHANGED
@@ -48,7 +48,7 @@ export async function handleMcp(req, res, ctx) {
48
48
  mcpResponse(id, {
49
49
  protocolVersion: PROTOCOL_VERSION,
50
50
  capabilities: { tools: { listChanged: false } },
51
- serverInfo: { name: 'arena-bridge', version: '0.6.7' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.6.9' },
52
52
  instructions:
53
53
  'Tools operate the user\'s own machine. Dangerous operations may return ' +
54
54
  'PROTECTED and require a local human approval.',
package/src/prompt.mjs CHANGED
@@ -97,6 +97,13 @@ ${tools.join('\n')}
97
97
  9. \`mac_spaces\` lists the macOS virtual desktops; \`mac_space_switch\` moves the agent to another
98
98
  Space so the user keeps the screen they are looking at.
99
99
 
100
+ ## Work log with evidence
101
+ Every call is logged with its arguments, result and duration. Screenshots taken on the desktop and
102
+ the accessibility trees that were read are stored next to the log.
103
+ - \`audit_log\` — the recent calls (filter by tool or time).
104
+ - \`audit_artifact\` — fetch a saved artifact: a screenshot (base64 PNG) or an accessibility tree (JSON).
105
+ Use them when you need to look back at what was on the screen or what the interface contained.
106
+
100
107
  ## Approvals
101
108
  ${trustLine}
102
109
  - When a call answers \`APPROVAL_PENDING\` it carries an \`approval_id\`: tell the user to run