arena-bridge 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ 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.4
8
+
9
+ ### Fixed
10
+ - The accessibility element map is read from Peekaboo's real payload (`data.ui_elements`), so
11
+ `mac_see` and `mac_element` now return the controls of a window instead of an empty list.
12
+ Elements carry their label, description, identifier and value-settable flag.
13
+ - Commands follow the Peekaboo v4 grammar: `set-value "<value>" --on <id>`, `menu click --item|--path`
14
+ with an explicit app, `space switch --to <n> --foreground`, and the tree-only read
15
+ `see --tree --no-screenshot --json` (no screenshot artifact for pure lookups).
16
+
17
+ ## 0.6.3
18
+
19
+ ### Added
20
+ - Desktop control now works through the macOS accessibility API first, not by pixel guessing:
21
+ - `mac_element` finds a control by its visible name, role or value and returns its id;
22
+ - `mac_set` writes a value straight into a control — no keystrokes, no focus change;
23
+ - `mac_menu` / `mac_menu_click` list and trigger menu items by name;
24
+ - `mac_wait` waits until a control appears or disappears instead of sleeping;
25
+ - `mac_spaces` / `mac_space_switch` move the agent to another macOS Space (virtual desktop).
26
+ - `mac_see` asks for the accessibility element map explicitly and explains what to do when the
27
+ element list is empty (missing Accessibility permission).
28
+
7
29
  ## 0.6.2
8
30
 
9
31
  ### 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-96%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-110%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -82,7 +82,7 @@ arena-bridge setup --root ~/projects
82
82
  | Шелл | `bash` `sysinfo` | команды и сведения о машине |
83
83
  | Процессы | `start_process` `read_process_output` `write_process` `kill_process` | фоновые дев-серверы |
84
84
  | 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 |
85
- | Десктоп | `mac_see` `mac_click` `mac_type` `mac_key` `mac_scroll` `mac_move` `mac_drag` `mac_apps` `mac_windows` `mac_app_launch` `mac_app_focus` `mac_app_quit` `mac_permissions` | macOS через Peekaboo |
85
+ | Десктоп | `mac_see` `mac_element` `mac_click` `mac_set` `mac_type` `mac_key` `mac_scroll` `mac_move` `mac_drag` `mac_menu` `mac_menu_click` `mac_wait` `mac_spaces` `mac_space_switch` `mac_apps` `mac_windows` `mac_app_launch` `mac_app_focus` `mac_app_quit` `mac_permissions` | macOS через Peekaboo |
86
86
 
87
87
  Полный каталог с JSON-схемами: `GET /tools` или `arena-bridge tools`.
88
88
 
@@ -93,7 +93,11 @@ arena-bridge setup --root ~/projects
93
93
  | прочитать страницу | `chrome_snapshot` (`{"mode":"ax"}` — роли и имена, `{"mode":"text"}`, `{"mode":"html"}`) |
94
94
  | кликнуть на странице | `chrome_click` (CSS-селектор или видимый текст) |
95
95
  | работать в вашем залогиненном Chrome | `chrome_attach` (порт 9222) и те же `chrome_*` |
96
- | посмотреть на экран или нативное приложение | `mac_see` (скриншот + id кликабельных элементов) |
96
+ | посмотреть на экран или нативное приложение | `mac_see` (скриншот + id элементов) |
97
+ | найти контрол по названию, без скриншота | `mac_element` (через Accessibility API: имя, роль, значение) |
98
+ | заполнить поле, не печатая и не забирая фокус | `mac_set` (значение пишется напрямую в элемент) |
99
+ | открыть пункт меню | `mac_menu` (список) → `mac_menu_click` (по названию) |
100
+ | дождаться, пока контрол появится | `mac_wait` |
97
101
  | кликнуть в нативном приложении | `mac_click` (id элемента, видимый текст или координаты) |
98
102
  | напечатать | `mac_type`, затем `mac_key` для Enter/Tab/cmd+s |
99
103
  | посмотреть файлы | `list_dir` (только верхний уровень; глубже — `{"recursive":true}`) |
@@ -141,9 +145,13 @@ cookies и localStorage нет.
141
145
 
142
146
  ```jsonc
143
147
  {"tool": "mac_see", "args": {"app": "Safari", "mode": "window"}} // скриншот + id элементов
144
- {"tool": "mac_click", "args": {"on": "B3"}} // клик по id из mac_see
148
+ {"tool": "mac_element", "args": {"name": "Save"}} // найти кнопку по названию
149
+ {"tool": "mac_click", "args": {"on": "B3"}} // клик по id из mac_see
150
+ {"tool": "mac_set", "args": {"on": "B2", "value": "hello"}} // вписать значение напрямую
145
151
  {"tool": "mac_type", "args": {"text": "hello", "submit": true}}
146
152
  {"tool": "mac_key", "args": {"key": "cmd+s"}}
153
+ {"tool": "mac_menu_click", "args": {"app": "Safari", "item": "Save"}} // пункт меню по названию
154
+ {"tool": "mac_wait", "args": {"name": "Saved", "timeout": 20}} // дождаться результата
147
155
  {"tool": "mac_apps", "args": {}} // что запущено
148
156
  {"tool": "mac_permissions", "args": {}} // чего не хватает в macOS
149
157
  ```
@@ -152,6 +160,26 @@ cookies и localStorage нет.
152
160
  macOS спросит разрешения **Screen Recording** и **Accessibility** для терминала, из которого
153
161
  запущен мост — без них скриншоты и клики не работают; `mac_permissions` покажет, что не так.
154
162
 
163
+ ### Что даёт доступ Accessibility
164
+
165
+ macOS отдаёт приложениям два разных слоя: картинку экрана и **Accessibility API** — дерево
166
+ элементов интерфейса с названиями, ролями и значениями. Второй слой полезен агенту сильнее:
167
+
168
+ | | по картинке | через Accessibility API |
169
+ | --- | --- | --- |
170
+ | как найти кнопку «Сохранить» | угадать координаты | `mac_element {"name":"Сохранить"}` → точный элемент |
171
+ | как нажать | клик по координате, может промахнуться | `mac_click {"on":"<id>"}` — по самому элементу |
172
+ | как вписать текст | свайп, фокус, клавиатура | `mac_set {"on":"<id>","value":"..."}` — без фокуса и без клавиш |
173
+ | как открыть «Файл → Экспорт» | перемещать курсор по меню | `mac_menu_click {"item":"Экспорт"}` — по названию |
174
+ | как убедиться, что получилось | смотреть скриншот | `mac_wait {"name":"Готово"}` — ждать появления |
175
+ | как заполнить поле | свайп + клавиатура + фокус | `mac_set {"on":"<id>","value":"..."}` — значение пишется в элемент |
176
+ | чужой ввод и фокус | мешает вам | действия уходят в фоновое окно |
177
+
178
+ Практический вывод: с выданным Accessibility агент работает заметно точнее и тише. Разрешение
179
+ даётся один раз в **Системные настройки → Конфиденциальность и безопасность → Универсальный
180
+ доступ** (для терминала, из которого запущен мост). `mac_permissions` показывает текущее
181
+ состояние разрешений.
182
+
155
183
  ---
156
184
 
157
185
  ## Доверенное окно: работать без подтверждений
@@ -199,7 +227,7 @@ arena-bridge browser close work # закрыть окно этого пр
199
227
  | --- | --- |
200
228
  | окно агента занимает экран | по умолчанию агент работает в фоне; видимое окно можно сделать маленьким и в углу: `chrome_launch {"headless":false,"width":900,"height":700,"x":-920,"y":40}` |
201
229
  | агент перехватывает фокус и курсор | действия в нативных приложениях идут «в фон» — в нужное окно, без переключения на вас; курсор двигается только по вашей явной просьбе |
202
- | окно агента на вашем рабочем столе | macOS умеет несколько рабочих столов: окно агента живёт на отдельном столе, вы — на своём |
230
+ | окно агента на вашем рабочем столе | macOS умеет несколько рабочих столов: агент переключается на отдельный стол через `mac_space_switch`, вы остаётесь на своём |
203
231
  | нужен отдельный «второй монитор» | в macOS виртуальный монитор штатно не создаётся; сторонние утилиты (BetterDisplay, DisplayLink) умеют — тогда окно агента всегда там, где вы не работаете |
204
232
  | нужен полностью независимый курсор и стол | только отдельная виртуальная машина; на 16 ГБ и Intel 2019 года она заметно тормозит и съедает 30–60 ГБ — как правило, не нужно |
205
233
 
@@ -295,8 +323,8 @@ npm run test:python # 44 теста Python: то же самое
295
323
  src/ Node-реализация (zero dependencies)
296
324
  ws.mjs минимальный WebSocket-клиент
297
325
  cdp.mjs клиент Chrome DevTools Protocol
298
- browser.mjs 12 инструментов chrome_*
299
- mac.mjs 13 инструментов mac_* (Peekaboo)
326
+ browser.mjs 13 инструментов chrome_*
327
+ mac.mjs 20 инструментов mac_* (Peekaboo, Accessibility API)
300
328
  trust.mjs окно доверия
301
329
  policy.mjs проверка путей (roots / protected)
302
330
  engine.mjs каталог, гейт подтверждений, аудит
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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/direct.mjs CHANGED
@@ -29,7 +29,7 @@ export function createDirectServer(cfg, engine) {
29
29
  ok: true,
30
30
  mode: 'direct',
31
31
  device: cfg.device,
32
- version: '0.6.2',
32
+ version: '0.6.4',
33
33
  uptime_sec: Math.round(process.uptime()),
34
34
  });
35
35
  }
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.2',
93
+ version: '0.6.4',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
package/src/mac.mjs CHANGED
@@ -100,21 +100,31 @@ function parseJson(text) {
100
100
  }
101
101
 
102
102
  function collectElements(node, out = [], depth = 0) {
103
- if (!node || depth > 6) return out;
103
+ if (!node || depth > 8) return out;
104
104
  if (Array.isArray(node)) {
105
105
  for (const n of node) collectElements(n, out, depth + 1);
106
106
  return out;
107
107
  }
108
108
  if (typeof node !== 'object') return out;
109
- if (node.elements && Array.isArray(node.elements)) {
110
- for (const e of node.elements) {
109
+ for (const key of ['ui_elements', 'elements']) {
110
+ const list = node[key];
111
+ if (!Array.isArray(list) || !list.length) continue;
112
+ for (const e of list) {
113
+ if (!e || typeof e !== 'object') continue;
114
+ const value = e.value;
111
115
  out.push({
112
- id: e.id ?? e.elementId ?? e.element_id ?? null,
113
- role: e.role ?? e.elementRole ?? e.type ?? null,
116
+ id: e.id ?? e.element_id ?? e.elementId ?? null,
117
+ role: e.role ?? e.ax_role ?? e.elementRole ?? e.type ?? null,
114
118
  title: e.title ?? e.label ?? e.name ?? e.placeholder ?? null,
115
- value: typeof e.value === 'string' ? e.value.slice(0, 200) : null,
116
- bounds: e.frame ?? e.bounds ?? e.rect ?? null,
117
- enabled: e.enabled ?? null,
119
+ label: e.label ?? null,
120
+ description: e.description ?? null,
121
+ identifier: e.identifier ?? null,
122
+ value: typeof value === 'string' ? value.slice(0, 200) : (value ?? null),
123
+ bounds: e.bounds ?? e.frame ?? e.rect ?? null,
124
+ enabled: e.is_enabled ?? e.enabled ?? null,
125
+ actionable: e.is_actionable ?? null,
126
+ settable: e.is_value_settable ?? null,
127
+ shortcut: e.keyboard_shortcut ?? null,
118
128
  });
119
129
  }
120
130
  return out;
@@ -175,6 +185,53 @@ export function macTools(cfg = {}) {
175
185
  return f;
176
186
  };
177
187
 
188
+ const seeVariants = (args, outPath) => {
189
+ const mode = args.mode === 'window' ? 'window' : 'screen';
190
+ const base = ['see', '--mode', mode, ...targetFlags(args)];
191
+ if (!outPath) {
192
+ return [
193
+ ['see', '--tree', '--no-screenshot', '--json', ...targetFlags(args)],
194
+ ['see', '--tree', '--json', ...targetFlags(args)],
195
+ ['see', '--json', ...targetFlags(args)],
196
+ ];
197
+ }
198
+ return [
199
+ [...base, '--json', '--path', outPath],
200
+ [...base, '--path', outPath, '--json'],
201
+ ['image', '--mode', mode, ...targetFlags(args), '--json', '--path', outPath],
202
+ ];
203
+ };
204
+
205
+ const collectSee = async (args, outPath) => {
206
+ let best = null;
207
+ for (const variant of seeVariants(args, outPath)) {
208
+ const res = await run(peek(), variant, { timeout: 60 });
209
+ const json = parseJson(res.stdout);
210
+ const cand = { res, json, elements: collectElements(json), variant };
211
+ if (!best) best = cand;
212
+ if (res.exit_code === 0 && cand.elements.length) return cand;
213
+ if (res.exit_code === 0 && best.res.exit_code !== 0) best = cand;
214
+ if (res.exit_code !== 0 && !UNKNOWN.test(res.stderr || '')) break;
215
+ }
216
+ return best;
217
+ };
218
+
219
+ const matchElement = (e, args) => {
220
+ const q = (v) => String(v ?? '').toLowerCase();
221
+ const text = [e.title, e.label, e.description, e.identifier, e.value, e.id]
222
+ .map(q)
223
+ .join('\n');
224
+ if (args.name && !text.includes(q(args.name))) return false;
225
+ if (args.role && !q(e.role).includes(q(args.role))) return false;
226
+ if (args.value && !q(e.value).includes(q(args.value))) return false;
227
+ return true;
228
+ };
229
+
230
+ const searchPath = () => path.join(os.tmpdir(), 'arena-bridge-see', 'see.png');
231
+
232
+ const searchSee = (args) =>
233
+ collectSee({ app: args.app, window_title: args.window_title, mode: args.mode || 'screen' }, null);
234
+
178
235
  return [
179
236
  {
180
237
  name: 'mac_see',
@@ -199,15 +256,8 @@ export function macTools(cfg = {}) {
199
256
  const outPath = args.path ? checkPath(String(args.path), cfg, ctx.base) : { ok: true, abs: defaultShotPath(cfg) };
200
257
  if (!outPath.ok) return { ok: false, error: outPath.error, reason: outPath.reason };
201
258
  ensureDir(path.dirname(outPath.abs));
202
- const mode = args.mode === 'window' ? 'window' : 'screen';
203
- const variants = [
204
- ['see', '--mode', mode, ...targetFlags(args), '--json', '--path', outPath.abs],
205
- ['see', '--mode', mode, ...targetFlags(args), '--path', outPath.abs, '--json'],
206
- ['image', '--mode', mode, ...targetFlags(args), '--json', '--path', outPath.abs],
207
- ];
208
- const res = await runVariants(p.bin, variants, { timeout: 60 });
209
- const json = parseJson(res.stdout);
210
- const elements = collectElements(json).slice(0, 300);
259
+ const seen = await collectSee(args, outPath.abs);
260
+ const elements = seen.elements.slice(0, 300);
211
261
  const saved = fs.existsSync(outPath.abs);
212
262
  if (!saved) {
213
263
  const fb = await captureScreenFallback(outPath.abs);
@@ -215,7 +265,7 @@ export function macTools(cfg = {}) {
215
265
  return {
216
266
  ok: false,
217
267
  error: 'capture_failed',
218
- reason: (res.stderr || '').trim().slice(0, 800),
268
+ reason: (seen.res.stderr || '').trim().slice(0, 800),
219
269
  hint: 'Grant Screen Recording permission to the app that runs the bridge (System Settings → Privacy & Security → Screen Recording).',
220
270
  };
221
271
  }
@@ -226,9 +276,12 @@ export function macTools(cfg = {}) {
226
276
  saved,
227
277
  elements,
228
278
  element_count: elements.length,
229
- hint: 'Click by element id: mac_click {"on":"<id>"} or by visible text: mac_click {"text":"Save"}',
279
+ hint: 'Click by element id: mac_click {"on":"<id>"} or by visible text: mac_click {"text":"Save"}. Find elements by name with mac_element.',
230
280
  };
231
- if (args.include_raw && json) out.raw = json;
281
+ if (!elements.length) {
282
+ out.hint += ' No elements found — run mac_permissions: the element list comes from the macOS Accessibility API.';
283
+ }
284
+ if (args.include_raw && seen.json) out.raw = seen.json;
232
285
  return out;
233
286
  },
234
287
  },
@@ -517,6 +570,227 @@ export function macTools(cfg = {}) {
517
570
  return finish(res, 'quit');
518
571
  },
519
572
  },
573
+ {
574
+ name: 'mac_element',
575
+ description:
576
+ 'Find interface elements by visible name, role or value through the macOS accessibility API. No screenshots, no pixel guessing. Read-only.',
577
+ approval: false,
578
+ schema: S(
579
+ {
580
+ name: { type: 'string', description: 'visible text or title to look for' },
581
+ role: { type: 'string', description: 'e.g. button, textField, menuItem, staticText' },
582
+ value: { type: 'string' },
583
+ app: { type: 'string' },
584
+ window_title: { type: 'string' },
585
+ limit: { type: 'number' },
586
+ },
587
+ [],
588
+ ),
589
+ required: [],
590
+ handler: async (args) => {
591
+ const p = checkPeek(peek());
592
+ if (!p.ok) return p;
593
+ const seen = await searchSee(args);
594
+ const matches = seen.elements.filter((e) => matchElement(e, args)).slice(0, Number(args.limit) || 25);
595
+ return {
596
+ ok: true,
597
+ matches,
598
+ count: matches.length,
599
+ scanned: seen.elements.length,
600
+ hint: 'Act on a match by id: mac_click {"on":"<id>"} or mac_set {"on":"<id>","value":"..."}.',
601
+ };
602
+ },
603
+ },
604
+ {
605
+ name: 'mac_set',
606
+ description:
607
+ 'Set the value of an element directly through the accessibility API — fills a field without focusing the window and without simulated keystrokes.',
608
+ approval: true,
609
+ schema: S(
610
+ {
611
+ on: { type: 'string', description: 'element id from mac_see / mac_element' },
612
+ value: { type: 'string' },
613
+ app: { type: 'string' },
614
+ window_id: { type: 'string' },
615
+ },
616
+ ['on', 'value'],
617
+ ),
618
+ required: ['on', 'value'],
619
+ handler: async (args) => {
620
+ const p = checkPeek(peek());
621
+ if (!p.ok) return p;
622
+ const flags = targetFlags(args);
623
+ const res = await runVariants(
624
+ p.bin,
625
+ [
626
+ ['set-value', String(args.value), '--on', String(args.on), ...flags],
627
+ ['set-value', String(args.value), '--on', String(args.on)],
628
+ ['set-value', '--on', String(args.on), String(args.value), ...flags],
629
+ ['set-value', '--on', String(args.on), '--value', String(args.value), ...flags],
630
+ ],
631
+ { timeout: 30 },
632
+ );
633
+ return finish(res, 'set-value');
634
+ },
635
+ },
636
+ {
637
+ name: 'mac_menu',
638
+ description: 'List the menu items of an application through the accessibility API. Read-only.',
639
+ approval: false,
640
+ schema: S({ app: { type: 'string' } }, []),
641
+ required: [],
642
+ handler: async (args) => {
643
+ const p = checkPeek(peek());
644
+ if (!p.ok) return p;
645
+ const flags = args.app ? ['--app', String(args.app)] : [];
646
+ const res = await runVariants(
647
+ p.bin,
648
+ [
649
+ ['menu', 'list', ...flags, '--json'],
650
+ ['menu', 'list', ...flags],
651
+ ],
652
+ { timeout: 30 },
653
+ );
654
+ const json = parseJson(res.stdout);
655
+ return {
656
+ ok: res.exit_code === 0,
657
+ menus: json ? (json.menus || json.data?.menus || json) : undefined,
658
+ output: json ? undefined : res.stdout.slice(0, 4000),
659
+ error: res.exit_code === 0 ? undefined : (res.stderr || '').trim().slice(0, 600),
660
+ };
661
+ },
662
+ },
663
+ {
664
+ name: 'mac_menu_click',
665
+ description:
666
+ 'Trigger a menu item by name (e.g. "Save", "Copy Link") or a nested path ("File > Export") — no pointer, no coordinates.',
667
+ approval: true,
668
+ schema: S(
669
+ {
670
+ item: { type: 'string', description: 'name of the menu item' },
671
+ path: { type: 'string', description: 'nested path, e.g. "File > Export"' },
672
+ app: { type: 'string', description: 'required: the background menu click needs a target app' },
673
+ },
674
+ [],
675
+ ),
676
+ required: [],
677
+ handler: async (args) => {
678
+ const p = checkPeek(peek());
679
+ if (!p.ok) return p;
680
+ if (!args.item && !args.path) {
681
+ return { ok: false, error: 'missing_item', hint: 'Pass item ("Save") or path ("File > Export").' };
682
+ }
683
+ if (!args.app) {
684
+ return {
685
+ ok: false,
686
+ error: 'missing_app',
687
+ hint: 'A background menu click needs an explicit app: mac_menu_click {"app":"Safari","item":"Save"}.',
688
+ };
689
+ }
690
+ const flags = ['--app', String(args.app)];
691
+ const variants = args.path
692
+ ? [
693
+ ['menu', 'click', '--path', String(args.path), ...flags],
694
+ ['menu', 'click', String(args.path), ...flags],
695
+ ]
696
+ : [
697
+ ['menu', 'click', '--item', String(args.item), ...flags],
698
+ ['menu', 'click', String(args.item), ...flags],
699
+ ];
700
+ const res = await runVariants(p.bin, variants, { timeout: 30 });
701
+ return finish(res, 'menu-click');
702
+ },
703
+ },
704
+ {
705
+ name: 'mac_spaces',
706
+ description: 'List the macOS Spaces (virtual desktops) and which one is active. Read-only.',
707
+ approval: false,
708
+ schema: S({}, []),
709
+ required: [],
710
+ handler: async () => {
711
+ const p = checkPeek(peek());
712
+ if (!p.ok) return p;
713
+ const res = await runVariants(
714
+ p.bin,
715
+ [
716
+ ['space', 'list', '--json'],
717
+ ['space', 'list'],
718
+ ],
719
+ { timeout: 30 },
720
+ );
721
+ const json = parseJson(res.stdout);
722
+ return {
723
+ ok: res.exit_code === 0,
724
+ spaces: json ? (json.spaces || json.data?.spaces || json) : undefined,
725
+ output: json ? undefined : res.stdout.slice(0, 4000),
726
+ error: res.exit_code === 0 ? undefined : (res.stderr || '').trim().slice(0, 600),
727
+ };
728
+ },
729
+ },
730
+ {
731
+ name: 'mac_space_switch',
732
+ description:
733
+ 'Move to another Space so the agent works on a separate virtual desktop and the user keeps the screen they are on.',
734
+ approval: true,
735
+ schema: S({ to: { type: 'string', description: 'space id or index from mac_spaces' } }, ['to']),
736
+ required: ['to'],
737
+ handler: async (args) => {
738
+ const p = checkPeek(peek());
739
+ if (!p.ok) return p;
740
+ const res = await runVariants(
741
+ p.bin,
742
+ [
743
+ ['space', 'switch', '--to', String(args.to), '--foreground'],
744
+ ['space', 'switch', String(args.to), '--foreground'],
745
+ ],
746
+ { timeout: 30 },
747
+ );
748
+ return finish(res, 'space-switch');
749
+ },
750
+ },
751
+ {
752
+ name: 'mac_wait',
753
+ description:
754
+ 'Wait until an element with the given name/role appears (or disappears) and report what was seen. Read-only.',
755
+ approval: false,
756
+ schema: S(
757
+ {
758
+ name: { type: 'string' },
759
+ role: { type: 'string' },
760
+ value: { type: 'string' },
761
+ disappear: { type: 'boolean', description: 'wait until the element is gone' },
762
+ timeout: { type: 'number', description: 'seconds, default 20, max 120' },
763
+ app: { type: 'string' },
764
+ },
765
+ [],
766
+ ),
767
+ required: [],
768
+ handler: async (args) => {
769
+ const p = checkPeek(peek());
770
+ if (!p.ok) return p;
771
+ const limit = Math.min(120, Math.max(1, Number(args.timeout) || 20));
772
+ const deadline = Date.now() + limit * 1000;
773
+ let scanned = 0;
774
+ while (Date.now() < deadline) {
775
+ const seen = await searchSee(args);
776
+ scanned = seen.elements.length;
777
+ const hit = seen.elements.find((e) => matchElement(e, args));
778
+ if (args.disappear) {
779
+ if (!hit) return { ok: true, gone: true, found: false, scanned };
780
+ } else if (hit) {
781
+ return { ok: true, found: true, element: hit, scanned };
782
+ }
783
+ await new Promise((r) => setTimeout(r, 2000));
784
+ }
785
+ return {
786
+ ok: false,
787
+ found: false,
788
+ error: 'not_found',
789
+ reason: `no matching element within ${limit}s`,
790
+ scanned,
791
+ };
792
+ },
793
+ },
520
794
  {
521
795
  name: 'mac_permissions',
522
796
  description:
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.2' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.6.4' },
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
@@ -59,6 +59,11 @@ ${tools.join('\n')}
59
59
  | click a native app | \`mac_click\` with the element id from \`mac_see\`, a visible text, or \`{"x":..,"y":..}\` |
60
60
  | type into a native app | \`mac_type\`, then \`mac_key\` for Enter/Tab/cmd+s |
61
61
  | list or open applications | \`mac_apps\`, \`mac_app_launch\`, \`mac_app_focus\` |
62
+ | find a control by its name, without a screenshot | \`mac_element\` (accessibility API: name, role, value) |
63
+ | fill a field without typing and without focus | \`mac_set\` (sets the value through the accessibility API) |
64
+ | use the application menu | \`mac_menu\` to list, \`mac_menu_click\` to trigger an item by name |
65
+ | wait for a control to appear or vanish | \`mac_wait\` |
66
+ | work on a separate virtual desktop | \`mac_spaces\`, \`mac_space_switch\` |
62
67
  | look at files | \`list_dir\` (top level only; add \`{"recursive":true}\` to go deeper), \`read_file\`, \`search_files\` |
63
68
 
64
69
  ## Browser control (Chrome over CDP)
@@ -75,6 +80,8 @@ ${tools.join('\n')}
75
80
  6. A visible window can be kept small and out of the way: \`chrome_launch {"headless":false,"width":900,"height":700,"x":-920,"y":40}\`.
76
81
 
77
82
  ## Desktop control (macOS, through Peekaboo)
83
+ 0. Prefer the accessibility API over pixels: controls are recognised by name and role, not by guessing
84
+ coordinates. \`mac_element\` finds them, \`mac_see\` returns the same elements with ids plus a screenshot.
78
85
  1. \`mac_see\` is the entry point: it takes a screenshot of the screen (or of one app window with
79
86
  \`{"app": "Safari", "mode": "window"}\`) and returns the clickable elements with their ids.
80
87
  2. Then \`mac_click\` (by id, by visible text or by coordinates) → \`mac_type\` → \`mac_key\` → \`mac_scroll\`.
@@ -84,6 +91,11 @@ ${tools.join('\n')}
84
91
  5. Screenshots are saved as PNG inside the authorized roots; read them back with \`file_get\`.
85
92
  6. Stay out of the user's way: do not pass \`foreground\`, do not focus windows and do not move the
86
93
  pointer unless the user asks for it. Background actions are delivered to the target window only.
94
+ 7. \`mac_set\` writes a value straight into a control (no keystrokes, no focus change);
95
+ \`mac_menu_click\` triggers a menu item by its name (it needs the \`app\` name) — both are more reliable than typing.
96
+ 8. \`mac_wait\` waits for a control to appear or disappear instead of sleeping and guessing.
97
+ 9. \`mac_spaces\` lists the macOS virtual desktops; \`mac_space_switch\` moves the agent to another
98
+ Space so the user keeps the screen they are looking at.
87
99
 
88
100
  ## Approvals
89
101
  ${trustLine}