arena-bridge 0.6.4 → 0.6.6

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,22 @@ 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.6
8
+
9
+ ### Fixed
10
+ - The tunnel is now supervised: when the tunnel binary dies, it is started again automatically
11
+ and the new public URL is printed and saved to the config, instead of leaving a dead tunnel
12
+ behind (the bridge used to keep running while the public address had stopped working).
13
+
14
+ ## 0.6.5
15
+
16
+ ### Fixed
17
+ - Peekaboo 4.9 compatibility: `peekaboo list` was removed in v4, so `mac_apps` now uses
18
+ `app list` and `mac_windows` uses `window list` (enumerating the running apps when no app is
19
+ given). `mac_menu` / `mac_menu_click` accept a process id, run against the local host
20
+ (`--no-remote`) and read the `menu_structure` payload; `mac_spaces` / `mac_space_switch` also
21
+ run locally, because a remote host cannot read or switch Spaces.
22
+
7
23
  ## 0.6.4
8
24
 
9
25
  ### Fixed
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-110%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-114%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -53,6 +53,10 @@ arena-bridge setup --root ~/projects --root ~/Documents
53
53
 
54
54
  `setup` печатает готовый промпт и публичный URL. Дальше — просто говорите агенту задачу.
55
55
 
56
+ Туннель под присмотром: если `cloudflared`/`ngrok` упадёт, мост поднимет его заново и сам
57
+ напечатает новый публичный адрес. Постоянный адрес вместо случайного — `arena-bridge tunnel
58
+ --bin ngrok --domain <зарезервированный домен>`.
59
+
56
60
  Нужен туннель? Поставьте `cloudflared` (`brew install cloudflared` или бинарник в `/usr/local/bin`).
57
61
  Можно и через ngrok — `arena-bridge tunnel --bin ngrok`; с сохранённым токеном и зарезервированным
58
62
  доменом публичный адрес остаётся постоянным, и промпт агенту не нужно копировать заново:
@@ -157,6 +161,7 @@ cookies и localStorage нет.
157
161
  ```
158
162
 
159
163
  Установить Peekaboo: `brew install peekaboo` (или бинарник в `/usr/local/bin/peekaboo`).
164
+ Проверено с Peekaboo 4.9 (4.3 тоже работает, но синтаксис команд обновлён под 4.9).
160
165
  macOS спросит разрешения **Screen Recording** и **Accessibility** для терминала, из которого
161
166
  запущен мост — без них скриншоты и клики не работают; `mac_permissions` покажет, что не так.
162
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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/cli.mjs CHANGED
@@ -199,7 +199,7 @@ async function cmdTunnel(args) {
199
199
  }
200
200
 
201
201
  console.log(`starting ${bin} (protocol=${protocol})…`);
202
- const { child, urlPromise } = startTunnel(cfg, {
202
+ const { child, urlPromise, onUrl } = startTunnel(cfg, {
203
203
  port,
204
204
  protocol,
205
205
  bin,
@@ -229,6 +229,15 @@ async function cmdTunnel(args) {
229
229
  cfg.port = port;
230
230
  saveConfig(cfg);
231
231
 
232
+ onUrl((url) => {
233
+ if (url === publicUrl) return;
234
+ publicUrl = url;
235
+ cfg.publicUrl = url;
236
+ saveConfig(cfg);
237
+ console.log(`\n⚠ tunnel reconnected — new public URL: ${url}`);
238
+ console.log('Update the base URL in the agent chat (arena-bridge prompt prints a fresh copy).');
239
+ });
240
+
232
241
  console.log(`\npublic URL: ${publicUrl} (saved as config.publicUrl)\n`);
233
242
  console.log('Paste this into the agent chat:');
234
243
  console.log('-----8<-----');
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.4',
32
+ version: '0.6.6',
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.4',
93
+ version: '0.6.6',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
package/src/mac.mjs CHANGED
@@ -191,6 +191,7 @@ export function macTools(cfg = {}) {
191
191
  if (!outPath) {
192
192
  return [
193
193
  ['see', '--tree', '--no-screenshot', '--json', ...targetFlags(args)],
194
+ ['see', '--tree', '--no-screenshot', '--no-remote', '--json', ...targetFlags(args)],
194
195
  ['see', '--tree', '--json', ...targetFlags(args)],
195
196
  ['see', '--json', ...targetFlags(args)],
196
197
  ];
@@ -198,6 +199,7 @@ export function macTools(cfg = {}) {
198
199
  return [
199
200
  [...base, '--json', '--path', outPath],
200
201
  [...base, '--path', outPath, '--json'],
202
+ [...base, '--no-remote', '--json', '--path', outPath],
201
203
  ['image', '--mode', mode, ...targetFlags(args), '--json', '--path', outPath],
202
204
  ];
203
205
  };
@@ -467,9 +469,9 @@ export function macTools(cfg = {}) {
467
469
  const res = await runVariants(
468
470
  p.bin,
469
471
  [
470
- ['list', 'apps', '--json'],
471
472
  ['app', 'list', '--json'],
472
- ['list', 'apps'],
473
+ ['list', 'apps', '--json'],
474
+ ['app', 'list'],
473
475
  ],
474
476
  { timeout: 30 },
475
477
  );
@@ -492,12 +494,16 @@ export function macTools(cfg = {}) {
492
494
  const p = checkPeek(peek());
493
495
  if (!p.ok) return p;
494
496
  const flags = args.app ? ['--app', String(args.app)] : [];
497
+ if (!flags.length) {
498
+ const windows = await windowsOfAllApps(p.bin);
499
+ return { ok: true, windows, source: 'per-app', hint: 'Pass app or pid to narrow the list down.' };
500
+ }
495
501
  const res = await runVariants(
496
502
  p.bin,
497
503
  [
498
- ['list', 'windows', ...flags, '--json'],
499
504
  ['window', 'list', ...flags, '--json'],
500
- ['list', 'windows', ...flags],
505
+ ['list', 'windows', ...flags, '--json'],
506
+ ['window', 'list', ...flags],
501
507
  ],
502
508
  { timeout: 30 },
503
509
  );
@@ -642,10 +648,11 @@ export function macTools(cfg = {}) {
642
648
  handler: async (args) => {
643
649
  const p = checkPeek(peek());
644
650
  if (!p.ok) return p;
645
- const flags = args.app ? ['--app', String(args.app)] : [];
651
+ const flags = menuFlags(args);
646
652
  const res = await runVariants(
647
653
  p.bin,
648
654
  [
655
+ ['menu', 'list', ...flags, '--no-remote', '--json'],
649
656
  ['menu', 'list', ...flags, '--json'],
650
657
  ['menu', 'list', ...flags],
651
658
  ],
@@ -654,9 +661,10 @@ export function macTools(cfg = {}) {
654
661
  const json = parseJson(res.stdout);
655
662
  return {
656
663
  ok: res.exit_code === 0,
657
- menus: json ? (json.menus || json.data?.menus || json) : undefined,
664
+ menus: json ? (json.menus || json.data?.menus || json.data?.menu_structure || json) : undefined,
658
665
  output: json ? undefined : res.stdout.slice(0, 4000),
659
666
  error: res.exit_code === 0 ? undefined : (res.stderr || '').trim().slice(0, 600),
667
+ hint: res.exit_code === 0 ? undefined : menuHint(res),
660
668
  };
661
669
  },
662
670
  },
@@ -670,6 +678,7 @@ export function macTools(cfg = {}) {
670
678
  item: { type: 'string', description: 'name of the menu item' },
671
679
  path: { type: 'string', description: 'nested path, e.g. "File > Export"' },
672
680
  app: { type: 'string', description: 'required: the background menu click needs a target app' },
681
+ pid: { type: 'number', description: 'process id, when the app name is ambiguous' },
673
682
  },
674
683
  [],
675
684
  ),
@@ -680,22 +689,22 @@ export function macTools(cfg = {}) {
680
689
  if (!args.item && !args.path) {
681
690
  return { ok: false, error: 'missing_item', hint: 'Pass item ("Save") or path ("File > Export").' };
682
691
  }
683
- if (!args.app) {
692
+ if (!args.app && !args.pid) {
684
693
  return {
685
694
  ok: false,
686
695
  error: 'missing_app',
687
696
  hint: 'A background menu click needs an explicit app: mac_menu_click {"app":"Safari","item":"Save"}.',
688
697
  };
689
698
  }
690
- const flags = ['--app', String(args.app)];
699
+ const flags = menuFlags(args);
691
700
  const variants = args.path
692
701
  ? [
702
+ ['menu', 'click', '--path', String(args.path), ...flags, '--no-remote'],
693
703
  ['menu', 'click', '--path', String(args.path), ...flags],
694
- ['menu', 'click', String(args.path), ...flags],
695
704
  ]
696
705
  : [
706
+ ['menu', 'click', '--item', String(args.item), ...flags, '--no-remote'],
697
707
  ['menu', 'click', '--item', String(args.item), ...flags],
698
- ['menu', 'click', String(args.item), ...flags],
699
708
  ];
700
709
  const res = await runVariants(p.bin, variants, { timeout: 30 });
701
710
  return finish(res, 'menu-click');
@@ -713,6 +722,7 @@ export function macTools(cfg = {}) {
713
722
  const res = await runVariants(
714
723
  p.bin,
715
724
  [
725
+ ['space', 'list', '--no-remote', '--json'],
716
726
  ['space', 'list', '--json'],
717
727
  ['space', 'list'],
718
728
  ],
@@ -740,8 +750,8 @@ export function macTools(cfg = {}) {
740
750
  const res = await runVariants(
741
751
  p.bin,
742
752
  [
753
+ ['space', 'switch', '--to', String(args.to), '--foreground', '--no-remote'],
743
754
  ['space', 'switch', '--to', String(args.to), '--foreground'],
744
- ['space', 'switch', String(args.to), '--foreground'],
745
755
  ],
746
756
  { timeout: 30 },
747
757
  );
@@ -821,6 +831,43 @@ export function macTools(cfg = {}) {
821
831
  ];
822
832
  }
823
833
 
834
+ function menuFlags(args) {
835
+ const f = [];
836
+ if (args.app) f.push('--app', String(args.app));
837
+ if (args.pid) f.push('--pid', String(args.pid));
838
+ return f;
839
+ }
840
+
841
+ function menuHint(res) {
842
+ const text = (res.stderr || res.stdout || '').trim();
843
+ if (/multiple apps match/i.test(text)) {
844
+ return 'Several apps share that name — pass the process id from mac_apps: mac_menu {"pid":12345}.';
845
+ }
846
+ return text.slice(0, 600);
847
+ }
848
+
849
+ async function windowsOfAllApps(bin) {
850
+ const res = await runVariants(
851
+ bin,
852
+ [
853
+ ['app', 'list', '--json'],
854
+ ['list', 'apps', '--json'],
855
+ ],
856
+ { timeout: 30 },
857
+ );
858
+ const json = parseJson(res.stdout);
859
+ const apps = json ? (json.apps || json.data?.apps || []) : [];
860
+ const out = [];
861
+ for (const app of Array.isArray(apps) ? apps.slice(0, 6) : []) {
862
+ if (!app || !app.pid) continue;
863
+ const w = await run(bin, ['window', 'list', '--pid', String(app.pid), '--json'], { timeout: 20 });
864
+ const data = parseJson(w.stdout);
865
+ const list = data ? (data.windows || data.data?.windows || []) : [];
866
+ for (const win of Array.isArray(list) ? list : []) out.push({ ...win, app: app.name, pid: app.pid });
867
+ }
868
+ return out;
869
+ }
870
+
824
871
  function checkPeek(p) {
825
872
  if (!p) return { ok: false, error: 'peekaboo_not_found', hint: NOT_FOUND_HINT };
826
873
  return { ok: true, bin: p };
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.4' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.6.6' },
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/tunnel.mjs CHANGED
@@ -9,7 +9,10 @@ const NGROK_URL_RE = /https:\/\/[a-z0-9-]+\.ngrok(?:-free)?\.(?:app|io|dev)/i;
9
9
  * cloudflared: QUIC (UDP/7844) is blocked on many networks, so we default to http2 over TCP/443.
10
10
  * ngrok: an authtoken and an optional reserved domain give the same URL on every restart.
11
11
  */
12
- export function startTunnel(cfg, { port, protocol = 'http2', bin = 'cloudflared', authtoken, domain } = {}) {
12
+ export function startTunnel(
13
+ cfg,
14
+ { port, protocol = 'http2', bin = 'cloudflared', authtoken, domain, restarts = 20, delay = 2000 } = {},
15
+ ) {
13
16
  const target = `http://127.0.0.1:${port}`;
14
17
  const isNgrok = /ngrok/i.test(bin);
15
18
  const args = isNgrok
@@ -24,12 +27,13 @@ export function startTunnel(cfg, { port, protocol = 'http2', bin = 'cloudflared'
24
27
  : ['tunnel', '--url', target, '--protocol', protocol];
25
28
  const urlPattern = isNgrok ? NGROK_URL_RE : URL_RE;
26
29
 
27
- const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
28
-
30
+ let child = null;
29
31
  let buffer = '';
30
32
  let settled = false;
33
+ let attempt = 0;
31
34
  let resolveUrl;
32
35
  let rejectUrl;
36
+ const listeners = new Set();
33
37
 
34
38
  const urlPromise = new Promise((resolve, reject) => {
35
39
  resolveUrl = resolve;
@@ -37,33 +41,48 @@ export function startTunnel(cfg, { port, protocol = 'http2', bin = 'cloudflared'
37
41
  });
38
42
 
39
43
  const onChunk = (chunk) => {
40
- const text = chunk.toString();
41
- buffer += text;
44
+ buffer += chunk.toString();
42
45
  const match = urlPattern.exec(buffer);
43
- if (match && !settled) {
46
+ if (!match) return;
47
+ if (!settled) {
44
48
  settled = true;
45
49
  resolveUrl(match[0]);
46
50
  }
51
+ for (const fn of listeners) fn(match[0]);
47
52
  };
48
53
 
49
- child.stdout.on('data', onChunk);
50
- child.stderr.on('data', onChunk);
51
-
52
- child.on('error', (e) => {
53
- if (!settled) {
54
- settled = true;
55
- rejectUrl(new Error(`cannot start ${bin}: ${e.message}`));
56
- }
57
- });
54
+ const spawnChild = () => {
55
+ child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
56
+ buffer = '';
57
+ child.stdout.on('data', onChunk);
58
+ child.stderr.on('data', onChunk);
59
+ child.on('error', (e) => {
60
+ if (!settled) {
61
+ settled = true;
62
+ rejectUrl(new Error(`cannot start ${bin}: ${e.message}`));
63
+ }
64
+ });
65
+ child.on('exit', (code) => {
66
+ if (!settled) {
67
+ settled = true;
68
+ rejectUrl(new Error(`${bin} exited with code ${code} before the tunnel URL appeared`));
69
+ return;
70
+ }
71
+ if (attempt++ < restarts) {
72
+ setTimeout(spawnChild, delay);
73
+ }
74
+ });
75
+ };
58
76
 
59
- child.on('exit', (code) => {
60
- if (!settled) {
61
- settled = true;
62
- rejectUrl(new Error(`${bin} exited with code ${code} before the tunnel URL appeared`));
63
- }
64
- });
77
+ spawnChild();
65
78
 
66
- return { child, urlPromise, args, target };
79
+ return {
80
+ child,
81
+ urlPromise,
82
+ args,
83
+ target,
84
+ onUrl: (fn) => listeners.add(fn),
85
+ };
67
86
  }
68
87
 
69
88
  export function isPortOpen(port, host = '127.0.0.1', timeout = 700) {