android-agent-bridge 0.0.0-stage → 0.1.1

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/server/cli.mjs ADDED
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env node
2
+ // android-agent-bridge (alias: phonectl)
3
+ // (no command) MCP server, launched by AI agents
4
+ // start run the bridge and show the pairing QR code
5
+ // usb set up a USB-connected phone (install, permissions, pair)
6
+ // init add the MCP server + agent guide to the current project
7
+ // status | screen | elements | tap | type | scroll | swipe | key | open | apps | wait
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { Bridge } from './bridge.mjs';
12
+ import { loadConfig, resetToken, APP_ID } from './config.mjs';
13
+ import { lanAddresses, pairLink, qrTerminal } from './hub.mjs';
14
+ import { adb, devices, pickSerial } from './adb.mjs';
15
+ import { fmtSize } from './files.mjs';
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const [cmd, ...rest] = process.argv.slice(2);
19
+
20
+ function parseArgs(list) {
21
+ const pos = [], flags = {};
22
+ for (let i = 0; i < list.length; i++) {
23
+ const a = list[i];
24
+ if (!a.startsWith('--')) { pos.push(a); continue; }
25
+ const key = a.slice(2);
26
+ if (list[i + 1] != null && !list[i + 1].startsWith('--')) flags[key] = list[++i];
27
+ else flags[key] = true;
28
+ }
29
+ return { pos, flags };
30
+ }
31
+
32
+ const num = (v) => (v == null || v === true ? undefined : Number(v));
33
+ const die = (msg) => { console.error(msg); process.exit(1); };
34
+
35
+ const HELP = `android-agent-bridge — let AI agents use your Android phone
36
+
37
+ Setup
38
+ start run the bridge and show the QR code to pair over Wi-Fi
39
+ usb set up a phone plugged in over USB (install app, permissions, pair)
40
+ init add the MCP server and agent guide to this project
41
+ pair [--reset] show the QR code again (--reset: new secret, phones must re-scan)
42
+ (no command) MCP server (what Claude Code / Cursor / Antigravity launch)
43
+
44
+ Control (needs a running bridge: \`start\`, \`usb\` or an agent session)
45
+ status connected phone and what it can do
46
+ screen [--mode marks|grid|both|plain] [--cols 12] [--out file]
47
+ screenshot with numbered elements (saved to a file) + element list
48
+ elements [--all] [--ocr] element list only (text); --all also lists elements hidden under sheets,
49
+ --ocr adds text read from the screen pixels
50
+ ocr text read from the screen pixels, with positions (finds what apps hide)
51
+ tap <target> [--long] target: element "12", grid cell "C7", "x,y" or visible text
52
+ (text not in the element list is found by OCR automatically)
53
+ type <text> [--into target] [--append] [--submit]
54
+ scroll <up|down|left|right> [--in target] [--amount 0.6]
55
+ swipe <from> <to> [--ms 300]
56
+ key <back|home|recents|notifications|quick_settings|enter|lock|power_dialog>
57
+ open <app> [--store] open an app by name or package; --store: if not installed, show it in the Play Store
58
+ launch <app> [--store] same as open
59
+ apps [filter]
60
+ current which app is in front
61
+ wait <text> [--timeout 10000]
62
+
63
+ Device (direct commands, no navigating Settings)
64
+ volume [media|ring|notification|alarm|call|system] [get|up|down|mute|unmute|max|<level>|<n>%] [--show]
65
+ ringer [normal|vibrate|silent]
66
+ brightness [get|0-100|auto|manual] needs "Modify system settings" once (permissions grant)
67
+ rotation [get|auto|portrait|landscape] needs "Modify system settings" once
68
+ flashlight <on|off>
69
+ dnd [get|on|alarms|total|off] Do Not Disturb; needs DND access once (permissions grant)
70
+ settings [page] [--app <package>] open a settings page: wifi, bluetooth, display, sound, battery, apps, ...
71
+ url <link> [--package <app>] open a web page or deep link (myapp://..., geo:, mailto:)
72
+ dial <number> open the dialer with the number filled in (never calls)
73
+ search <query> web search on the phone
74
+ store <app name> | --package <id> Play Store page / search
75
+ battery level, charging, temperature, power saver
76
+ wake turn the screen on (a PIN lock stays locked)
77
+ clipboard [set <text>] read / set the phone clipboard
78
+ notifications [--limit 20] [--clear] notifications seen since the service started
79
+ permissions [grant] what the app may do; grant: allow the one-time permissions over USB
80
+ stop <package> force-stop an app (USB)
81
+
82
+ Transfer (Wi-Fi or USB)
83
+ send <file> [file...] send files to the phone (they land in Downloads/Agent Bridge)
84
+ received files the phone sent to this computer, and where they are
85
+ drop drag-and-drop page for sending files: http://localhost:8350/drop
86
+ `;
87
+
88
+ async function control() {
89
+ const { pos, flags } = parseArgs(rest);
90
+ const bridge = new Bridge({ host: false });
91
+ const run = (c, a, t) => bridge.run(c, a, t);
92
+ const outline = (r) => r.outline && console.log(r.outline);
93
+ const via = (r) => (r.via === 'adb' ? ' (through adb)' : '');
94
+ const chooser = async () => {
95
+ const c = await run('current_app');
96
+ if (c.package === 'android') console.log('Android is asking which app to use ("Complete action using"): tap one, e.g. `tap Phone`.');
97
+ };
98
+ switch (cmd) {
99
+ case 'status': {
100
+ const s = await run('status');
101
+ if (!s.phones.length) return console.log(`No phone connected. Pair at http://localhost:${s.port}/pair`);
102
+ for (const p of s.phones) console.log(`${p.manufacturer} ${p.model} — Android ${p.release} (API ${p.sdk}), ${p.screenW}x${p.screenH}, via ${p.via}, gestures: ${p.gestures}, screenshots: ${p.screenshot}`);
103
+ return;
104
+ }
105
+ case 'screen': {
106
+ const r = await run('screen', { mode: flags.mode, cols: num(flags.cols), maxSide: num(flags['max-side']) }, 30000);
107
+ if (r.image) {
108
+ const out = path.resolve(typeof flags.out === 'string' ? flags.out : 'phone-screen.' + (r.mime === 'image/png' ? 'png' : 'jpg'));
109
+ fs.writeFileSync(out, Buffer.from(r.image, 'base64'));
110
+ console.log('Screenshot: ' + out);
111
+ }
112
+ if (r.note || r.captureError) console.log(r.note || 'No screenshot: ' + r.captureError);
113
+ return outline(r);
114
+ }
115
+ case 'elements': {
116
+ outline(await run('elements', { all: !!flags.all }));
117
+ if (flags.ocr) console.log('\n' + (await run('ocr')).outline);
118
+ return;
119
+ }
120
+ case 'ocr': return console.log((await run('ocr', {}, 45000)).outline);
121
+ case 'tap': {
122
+ if (!pos[0]) die('usage: tap <target> [--long]');
123
+ const r = await run('tap', { target: pos.join(' '), long: !!flags.long });
124
+ console.log(`Tapped at ${r.x},${r.y}${r.via === 'ocr' ? ` (found "${r.matched}" by reading the screen)` : via(r)}`);
125
+ return outline(r);
126
+ }
127
+ case 'type': {
128
+ if (!pos.length) die('usage: type <text> [--into target] [--append] [--submit]');
129
+ const r = await run('type', { text: pos.join(' '), target: flags.into, append: !!flags.append, submit: !!flags.submit });
130
+ console.log('Typed' + via(r));
131
+ return outline(r);
132
+ }
133
+ case 'scroll': {
134
+ if (!pos[0]) die('usage: scroll <up|down|left|right> [--in target]');
135
+ const r = await run('scroll', { direction: pos[0], target: flags.in, amount: num(flags.amount) });
136
+ console.log('Scrolled ' + pos[0] + via(r));
137
+ return outline(r);
138
+ }
139
+ case 'swipe': {
140
+ if (pos.length < 2) die('usage: swipe <from> <to> [--ms 300]');
141
+ const r = await run('swipe', { from: pos[0], to: pos[1], ms: num(flags.ms) });
142
+ console.log('Swiped' + via(r));
143
+ return outline(r);
144
+ }
145
+ case 'key': {
146
+ if (!pos[0]) die('usage: key <name>');
147
+ const r = await run('key', { key: pos[0] });
148
+ console.log('Pressed ' + pos[0] + via(r));
149
+ return outline(r);
150
+ }
151
+ case 'open':
152
+ case 'launch': {
153
+ if (!pos.length) die('usage: open <app> [--store]');
154
+ const r = await run('open_app', { name: pos.join(' '), settle: 5000, store: !!flags.store });
155
+ console.log(r.installed === false ? `${pos.join(' ')} is not installed; opened the Play Store so you can install it` : `Opened ${r.label} (${r.package})`);
156
+ return outline(r);
157
+ }
158
+ case 'current': {
159
+ const r = await run('current_app');
160
+ return console.log(r.package ? `${r.label} (${r.package})` : 'No app in front');
161
+ }
162
+ case 'volume': {
163
+ const streams = ['media', 'ring', 'notification', 'alarm', 'call', 'system'];
164
+ const stream = streams.includes(pos[0]) ? pos.shift() : 'media';
165
+ const r = await run('volume', { stream, action: pos[0] || 'get', show: !!flags.show });
166
+ console.log(`${r.stream} volume ${r.level}/${r.max} · ringer ${r.ringer}`);
167
+ return console.log(Object.entries(r.all).map(([k, v]) => ` ${k.padEnd(13)}${v.level}/${v.max}`).join('\n'));
168
+ }
169
+ case 'ringer': { const r = await run('ringer', { mode: pos[0] || '' }); return console.log('Ringer: ' + r.ringer); }
170
+ case 'brightness': { const r = await run('brightness', { value: pos[0] || 'get' }); return console.log(`Brightness ${r.percent}%${r.auto ? ' (auto)' : ''}`); }
171
+ case 'rotation': { const r = await run('rotation', { value: pos[0] || 'get' }); return console.log(r.auto ? 'Rotation: auto' : `Rotation: locked ${r.locked}`); }
172
+ case 'flashlight':
173
+ case 'torch': { if (!pos[0]) die('usage: flashlight <on|off>'); const r = await run('torch', { state: pos[0] }); return console.log('Flashlight ' + r.flashlight); }
174
+ case 'dnd': { const r = await run('dnd', { state: pos[0] || 'get' }); return console.log('Do Not Disturb: ' + r.dnd); }
175
+ case 'settings': {
176
+ const app = typeof flags.app === 'string' ? flags.app : '';
177
+ const r = await run('settings_page', { page: pos[0] || (app ? '' : 'settings'), app });
178
+ return console.log('Opened ' + r.opened);
179
+ }
180
+ case 'url': { if (!pos[0]) die('usage: url <link>'); const r = await run('view_url', { url: pos[0], package: typeof flags.package === 'string' ? flags.package : '' }); console.log('Opened ' + r.opened); return chooser(); }
181
+ case 'dial': { if (!pos[0]) die('usage: dial <number>'); const r = await run('dial', { number: pos.join('') }); console.log(`Dialer open with ${r.dialer} (not calling)`); return chooser(); }
182
+ case 'search': { if (!pos.length) die('usage: search <query>'); const r = await run('web_search', { query: pos.join(' ') }); console.log('Searching for ' + r.searched); return chooser(); }
183
+ case 'store': { const r = await run('store', { query: pos.join(' '), package: typeof flags.package === 'string' ? flags.package : '' }); return console.log('Opened ' + r.opened); }
184
+ case 'battery': {
185
+ const r = await run('battery');
186
+ return console.log(`Battery ${r.percent}% · ${r.charging ? 'charging' : 'not charging'} (plugged: ${r.plugged}) · ${r.temperature_c}°C · power saver ${r.power_saver ? 'on' : 'off'} · screen ${r.screen_on ? 'on' : 'off'}`);
187
+ }
188
+ case 'wake': { await run('wake'); return console.log('Screen on (a PIN or pattern lock stays locked)'); }
189
+ case 'clipboard': {
190
+ const r = await run('clipboard', pos[0] === 'set' ? { set: pos.slice(1).join(' ') } : {});
191
+ return console.log(pos[0] === 'set' ? 'Clipboard set' : r.clipboard == null ? '(clipboard empty or not readable: Android 10+ hides it from background apps)' : r.clipboard);
192
+ }
193
+ case 'notifications': {
194
+ const r = await run('notifications', { limit: num(flags.limit) ?? 20, clear: !!flags.clear });
195
+ if (!r.notifications.length) return console.log('No notifications seen since the service started.');
196
+ return console.log(r.notifications.map((n) => `${new Date(n.time).toLocaleTimeString()} ${n.app} ${n.text}`).join('\n'));
197
+ }
198
+ case 'permissions': {
199
+ const r = await run(pos[0] === 'grant' ? 'grant_permissions' : 'permissions', {}, 30000);
200
+ if (r.granted) console.log('Granted over USB: ' + (r.granted.join(', ') || 'nothing'));
201
+ const p = r.permissions || r;
202
+ return console.log(Object.entries(p).map(([k, v]) => ` ${k.replace(/_/g, ' ').padEnd(24)}${v === true ? 'yes' : v === false ? 'NO' : v}`).join('\n'));
203
+ }
204
+ case 'send': {
205
+ if (!pos.length) die('usage: send <file> [file...]');
206
+ const files = pos.map((p) => path.resolve(p));
207
+ for (const f of files) if (!fs.existsSync(f) || !fs.statSync(f).isFile()) die(`not a file: ${f}`);
208
+ const r = await run('send_files', { paths: files }, 30 * 60 * 1000);
209
+ return console.log(r.map((x) => `Sent ${x.name} (${fmtSize(x.size)}) → phone: ${x.saved}`).join('\n'));
210
+ }
211
+ case 'received': {
212
+ const r = await run('received_files', { limit: num(flags.limit) ?? 20 });
213
+ console.log('Folder: ' + r.folder);
214
+ return console.log(r.files.length ? r.files.map((f) => ` ${new Date(f.time).toLocaleString()} ${fmtSize(f.size).padStart(9)} ${f.name}`).join('\n') : ' (nothing yet)');
215
+ }
216
+ case 'drop': return console.log(`Open http://localhost:${loadConfig().port}/drop in your browser to drag files to the phone.`);
217
+ case 'stop': { if (!pos[0]) die('usage: stop <package>'); const r = await run('force_stop', { package: pos[0] }); return console.log('Stopped ' + r.stopped); }
218
+ case 'apps': {
219
+ const r = await run('apps', { filter: pos.join(' ') || undefined });
220
+ return console.log(r.apps.map((x) => `${x.label} — ${x.package}`).join('\n'));
221
+ }
222
+ case 'wait': {
223
+ const timeout = num(flags.timeout) ?? 10000;
224
+ const r = await run('wait_text', { text: pos.join(' '), timeout }, timeout + 5000);
225
+ console.log(r.found ? 'Found' : 'Not found');
226
+ return outline(r);
227
+ }
228
+ }
229
+ }
230
+
231
+ async function start({ quiet = false } = {}) {
232
+ const bridge = new Bridge({ host: true, log: (m) => console.log(`[${new Date().toLocaleTimeString()}] ${m}`) });
233
+ await bridge.ensure();
234
+ const cfg = bridge.cfg;
235
+ const link = pairLink(cfg);
236
+ if (!bridge.hub) {
237
+ console.log(`A bridge is already running on port ${cfg.port}. Pairing page: http://localhost:${cfg.port}/pair`);
238
+ if (!quiet) process.exit(0);
239
+ return bridge;
240
+ }
241
+ if (quiet) return bridge;
242
+ console.log('\nScan with the Agent Bridge app (phone on the same Wi-Fi):\n');
243
+ console.log(await qrTerminal(link));
244
+ console.log(`Bigger QR code: http://localhost:${cfg.port}/pair`);
245
+ if (!lanAddresses().length) console.log('No Wi-Fi/LAN address found on this computer; use `usb` instead.');
246
+ console.log('Agents (MCP or CLI) on this computer now share this bridge. Ctrl+C to stop.\n');
247
+ return bridge;
248
+ }
249
+
250
+ function findApk() {
251
+ return [
252
+ path.join(here, '..', 'apk', 'agent-bridge.apk'),
253
+ path.join(here, '..', 'android', 'app', 'build', 'outputs', 'apk', 'release', 'app-release.apk'),
254
+ path.join(here, '..', 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'),
255
+ ].find((f) => fs.existsSync(f));
256
+ }
257
+
258
+ async function usb() {
259
+ const { flags } = parseArgs(rest);
260
+ const list = await devices().catch((e) => die(e.message));
261
+ if (!list.length) {
262
+ die('No phone found over USB.\n1. Settings → About phone → tap "Build number" 7 times (developer mode).\n2. Settings → Developer options → turn on USB debugging.\n3. Plug the phone in and accept the "Allow USB debugging?" prompt.');
263
+ }
264
+ const serial = await pickSerial();
265
+ const sh = (...c) => adb(['shell', ...c], { serial });
266
+ const cfg = loadConfig();
267
+ const sdk = Number(await sh('getprop', 'ro.build.version.sdk'));
268
+ const model = await sh('getprop', 'ro.product.model');
269
+ console.log(`Phone: ${model} (API ${sdk}), serial ${serial}`);
270
+ if (sdk < 21) die('Android 5.0 (API 21) or newer is required.');
271
+
272
+ const installed = (await sh('pm', 'path', APP_ID).catch(() => '')).includes('package:');
273
+ if (!installed || flags.reinstall) {
274
+ const apk = typeof flags.apk === 'string' ? flags.apk : findApk();
275
+ if (!apk) die('The Agent Bridge app is not installed and no APK was found. Install it on the phone first (see README), or pass --apk <file>.');
276
+ console.log('Installing ' + path.basename(apk) + ' …');
277
+ await adb(['install', '-r', apk], { serial, timeout: 120000 });
278
+ }
279
+
280
+ // bridge first, so the phone connects the moment it is paired
281
+ const bridge = await start({ quiet: true });
282
+ await adb(['reverse', `tcp:${cfg.port}`, `tcp:${cfg.port}`], { serial });
283
+ console.log(`✔ USB tunnel on port ${cfg.port}`);
284
+
285
+ const comp = `${APP_ID}/${APP_ID}.AgentService`;
286
+ const cur = (await sh('settings', 'get', 'secure', 'enabled_accessibility_services')).trim();
287
+ if (!cur.includes(comp)) {
288
+ const next = cur && cur !== 'null' ? `${cur}:${comp}` : comp;
289
+ await sh('settings', 'put', 'secure', 'enabled_accessibility_services', next);
290
+ await sh('settings', 'put', 'secure', 'accessibility_enabled', '1');
291
+ }
292
+ console.log('✔ Accessibility service on');
293
+
294
+ const link = pairLink(cfg, ['127.0.0.1', ...lanAddresses()]);
295
+ await sh('am', 'start', '-a', 'android.intent.action.VIEW', '-d', `'${link}'`, APP_ID);
296
+ console.log('✔ Paired (USB first, Wi-Fi when unplugged)');
297
+ if (sdk < 24) console.log('• Android ' + sdk + ': taps by coordinate and grid go through adb, so keep USB plugged in for those.');
298
+ if (sdk < 30) console.log('• For screenshots over Wi-Fi tap "Allow screen capture" in the app (over USB they work anyway).');
299
+ if (!bridge.hub) process.exit(0);
300
+ console.log('\nBridge running. Agents (MCP or CLI) on this computer use it. Ctrl+C to stop.\n');
301
+ }
302
+
303
+ function writeMcpConfig(file) {
304
+ let cfg = {};
305
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
306
+ cfg.mcpServers ||= {};
307
+ // run from a source checkout (not installed from npm): point at this copy
308
+ const local = !/node_modules|[\\/]_npx[\\/]/.test(here);
309
+ cfg.mcpServers.phone = local
310
+ ? { command: 'node', args: [path.join(here, 'cli.mjs')] }
311
+ : process.platform === 'win32'
312
+ ? { command: 'cmd', args: ['/c', 'npx', '-y', 'android-agent-bridge'] }
313
+ : { command: 'npx', args: ['-y', 'android-agent-bridge'] };
314
+ fs.mkdirSync(path.dirname(file), { recursive: true });
315
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
316
+ }
317
+
318
+ function init() {
319
+ const root = process.cwd();
320
+ const done = [];
321
+ writeMcpConfig(path.join(root, '.mcp.json'));
322
+ done.push('.mcp.json (Claude Code)');
323
+ if (fs.existsSync(path.join(root, '.cursor')) || rest.includes('--cursor')) {
324
+ writeMcpConfig(path.join(root, '.cursor', 'mcp.json'));
325
+ done.push('.cursor/mcp.json (Cursor)');
326
+ }
327
+ const skillSrc = path.join(here, '..', 'skill', 'SKILL.md');
328
+ const skillDst = path.join(root, '.claude', 'skills', 'phone-control', 'SKILL.md');
329
+ fs.mkdirSync(path.dirname(skillDst), { recursive: true });
330
+ fs.copyFileSync(skillSrc, skillDst);
331
+ done.push('.claude/skills/phone-control/SKILL.md (how the agent uses the phone)');
332
+ const agents = path.join(root, 'AGENTS.md');
333
+ const guide = fs.readFileSync(skillSrc, 'utf8').replace(/^---[\s\S]*?---\n/, '');
334
+ const section = `<!-- phone-control:start -->\n${guide.trim()}\n<!-- phone-control:end -->\n`;
335
+ const existing = fs.existsSync(agents) ? fs.readFileSync(agents, 'utf8') : '';
336
+ fs.writeFileSync(agents, existing.includes('<!-- phone-control:start -->')
337
+ ? existing.replace(/<!-- phone-control:start -->[\s\S]*?<!-- phone-control:end -->\n?/, section)
338
+ : (existing ? existing.trimEnd() + '\n\n' : '') + section);
339
+ done.push('AGENTS.md (phone-control section) (Antigravity, Cursor, others)');
340
+ console.log('\n✔ android-agent-bridge set up:\n ' + done.join('\n '));
341
+ console.log('\nNext: restart your agent, then pair the phone (`npx android-agent-bridge start` or `usb`).\n');
342
+ }
343
+
344
+ try {
345
+ if (!cmd || cmd === 'mcp') {
346
+ const { startServer } = await import('./mcp.mjs');
347
+ await startServer();
348
+ } else if (cmd === 'help' || cmd === '--help' || cmd === '-h') console.log(HELP);
349
+ else if (cmd === 'start') await start();
350
+ else if (cmd === 'usb') await usb();
351
+ else if (cmd === 'init') init();
352
+ else if (cmd === 'pair') {
353
+ const cfg = parseArgs(rest).flags.reset ? resetToken() : loadConfig();
354
+ const link = pairLink(cfg);
355
+ console.log(await qrTerminal(link));
356
+ console.log(link + `\n\nThe bridge must be running (\`start\`) for the phone to connect. Page: http://localhost:${cfg.port}/pair`);
357
+ } else if (['status', 'screen', 'elements', 'ocr', 'tap', 'type', 'scroll', 'swipe', 'key', 'open', 'launch', 'apps', 'current', 'wait', 'volume', 'ringer', 'brightness', 'rotation', 'flashlight', 'torch', 'dnd', 'settings', 'url', 'dial', 'search', 'store', 'battery', 'wake', 'clipboard', 'notifications', 'permissions', 'stop', 'send', 'received', 'drop'].includes(cmd)) await control();
358
+ else die(`Unknown command "${cmd}".\n\n` + HELP);
359
+ } catch (err) {
360
+ die('Error: ' + err.message);
361
+ }
@@ -0,0 +1,36 @@
1
+ // Pairing secret and port, kept in ~/.android-agent-bridge/config.json so a
2
+ // paired phone reconnects on its own after the computer restarts.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+
8
+ export const CONFIG_DIR = process.env.AAB_HOME || path.join(os.homedir(), '.android-agent-bridge');
9
+ const FILE = path.join(CONFIG_DIR, 'config.json');
10
+ export const DEFAULT_PORT = Number(process.env.AAB_PORT) || 8350;
11
+ export const APP_ID = 'io.github.rudrasamadhiya.agentbridge';
12
+
13
+ function save(cfg) {
14
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
15
+ fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
16
+ }
17
+
18
+ export function loadConfig() {
19
+ let cfg = null;
20
+ try { cfg = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch {}
21
+ if (!cfg?.token) {
22
+ cfg = { token: crypto.randomBytes(16).toString('hex'), port: DEFAULT_PORT };
23
+ save(cfg);
24
+ }
25
+ if (process.env.AAB_PORT) cfg.port = DEFAULT_PORT;
26
+ cfg.port ||= DEFAULT_PORT;
27
+ return cfg;
28
+ }
29
+
30
+ /** New secret: every paired phone has to scan again. */
31
+ export function resetToken() {
32
+ const cfg = loadConfig();
33
+ cfg.token = crypto.randomBytes(16).toString('hex');
34
+ save(cfg);
35
+ return cfg;
36
+ }
@@ -0,0 +1,76 @@
1
+ // The drag-and-drop page at http://localhost:<port>/drop: send files to the phone,
2
+ // see what the phone has sent to this computer.
3
+ export function dropPage() {
4
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Agent Bridge Transfer</title>
6
+ <style>
7
+ :root{--bg:#0a0b0d;--card:#15171c;--card2:#1c1f25;--line:#262a31;--fg:#f2f4f7;--muted:#9aa3af;--faint:#6b7380;--acc:#3ddc97;--on:#06140e;--err:#f87171}
8
+ @media (prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--card2:#f0f2f5;--line:#e3e6eb;--fg:#111418;--muted:#5b6370;--faint:#8a919c;--acc:#0f9d63;--on:#fff;--err:#c0392b}}
9
+ *{box-sizing:border-box}
10
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
11
+ main{max-width:720px;margin:0 auto;padding:32px 16px 64px}
12
+ header{display:flex;align-items:center;gap:12px}
13
+ .mark{width:36px;height:36px;border-radius:10px;background:var(--card);border:1px solid var(--line);display:grid;place-items:center;font:600 15px ui-monospace,Consolas,monospace;color:var(--acc)}
14
+ h1{font-size:20px;margin:0}
15
+ .sub{color:var(--muted);font-size:13px}
16
+ #phone{margin-left:auto;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:8px}
17
+ #phone i{width:8px;height:8px;border-radius:50%;background:var(--faint)}
18
+ #phone.on i{background:var(--acc);box-shadow:0 0 0 4px color-mix(in srgb,var(--acc) 25%,transparent)}
19
+ .zone{display:block;margin-top:24px;border:1.5px dashed var(--line);border-radius:16px;background:var(--card);padding:48px 16px;text-align:center;cursor:pointer;transition:.15s}
20
+ .zone.over{border-color:var(--acc);background:color-mix(in srgb,var(--acc) 8%,var(--card))}
21
+ .zone b{display:block;font-size:17px}
22
+ .zone span{color:var(--muted);font-size:13px}
23
+ .btn{display:inline-block;margin-top:14px;background:var(--acc);color:var(--on);border:0;border-radius:10px;padding:9px 16px;font-weight:600;font-size:14px;cursor:pointer}
24
+ h2{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin:32px 4px 10px}
25
+ .list{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden}
26
+ .row{display:flex;align-items:center;gap:12px;padding:12px 14px;border-top:1px solid var(--line)}
27
+ .row:first-child{border-top:0}
28
+ .row .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
29
+ .row .m{color:var(--muted);font-size:13px;white-space:nowrap}
30
+ .bar{height:3px;background:var(--card2);border-radius:2px;overflow:hidden;margin-top:6px}
31
+ .bar i{display:block;height:100%;width:0;background:var(--acc);transition:width .2s}
32
+ .empty{padding:16px;color:var(--muted);font-size:14px}
33
+ .ok{color:var(--acc)}.bad{color:var(--err)}
34
+ code{font:13px ui-monospace,Consolas,monospace;color:var(--muted);word-break:break-all}
35
+ </style></head><body><main>
36
+ <header><div class="mark">&gt;_</div><div><h1>Transfer</h1><div class="sub">Between this computer and your phone</div></div>
37
+ <div id="phone"><i></i><span>Checking…</span></div></header>
38
+
39
+ <label class="zone" id="zone"><input type="file" id="pick" multiple hidden>
40
+ <b>Drop files to send to your phone</b><span>They land in Downloads / Agent Bridge on the phone</span><br><span class="btn">Choose files</span></label>
41
+
42
+ <h2>Sending</h2><div class="list" id="out"><div class="empty">Nothing sent yet.</div></div>
43
+ <h2>Received from the phone</h2><div class="list" id="in"><div class="empty">Loading…</div></div>
44
+ <p class="sub" style="margin:10px 4px">Saved in <code id="folder"></code>. On the phone: Agent Bridge → Transfer → Send to computer, or Share → Agent Bridge from any app.</p>
45
+ </main><script>
46
+ const fmt=n=>n<1024?n+' B':n<1048576?(n/1024).toFixed(1)+' KB':n<1073741824?(n/1048576).toFixed(1)+' MB':(n/1073741824).toFixed(2)+' GB';
47
+ const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
48
+ const out=document.getElementById('out'),zone=document.getElementById('zone');
49
+ function send(file){
50
+ if(out.querySelector('.empty'))out.innerHTML='';
51
+ const row=document.createElement('div');row.className='row';
52
+ row.innerHTML='<div class="n">'+esc(file.name)+'<div class="bar"><i></i></div></div><div class="m">'+fmt(file.size)+'</div>';
53
+ out.prepend(row);
54
+ const bar=row.querySelector('.bar i'),m=row.querySelector('.m');
55
+ const x=new XMLHttpRequest();x.open('POST','/files/drop?name='+encodeURIComponent(file.name));
56
+ x.upload.onprogress=e=>{if(e.lengthComputable)bar.style.width=(e.loaded/e.total*90)+'%'};
57
+ x.onload=()=>{let r={};try{r=JSON.parse(x.responseText)}catch{}
58
+ if(r.ok){bar.style.width='100%';m.innerHTML='<span class="ok">On phone</span>';m.title=r.result&&r.result.saved||''}
59
+ else{m.innerHTML='<span class="bad">'+esc(r.error||'Failed')+'</span>'}};
60
+ x.onerror=()=>{m.innerHTML='<span class="bad">Failed</span>'};
61
+ x.send(file);
62
+ }
63
+ const many=fs=>[...fs].forEach(send);
64
+ document.getElementById('pick').onchange=e=>many(e.target.files);
65
+ ['dragenter','dragover'].forEach(t=>zone.addEventListener(t,e=>{e.preventDefault();zone.classList.add('over')}));
66
+ ['dragleave','drop'].forEach(t=>zone.addEventListener(t,e=>{e.preventDefault();zone.classList.remove('over')}));
67
+ zone.addEventListener('drop',e=>many(e.dataTransfer.files));
68
+ async function poll(){
69
+ try{const s=(await (await fetch('/status')).json()).result,p=s.phones[0],el=document.getElementById('phone');
70
+ el.className=p?'on':'';el.querySelector('span').textContent=p?(p.deviceName||p.model)+' · '+p.via:'No phone connected';}catch{}
71
+ try{const r=(await (await fetch('/files/received')).json()).result;document.getElementById('folder').textContent=r.folder;
72
+ document.getElementById('in').innerHTML=r.files.length?r.files.map(f=>'<div class="row"><div class="n">'+esc(f.name)+'</div><div class="m">'+fmt(f.size)+' · '+new Date(f.time).toLocaleTimeString()+'</div></div>').join(''):'<div class="empty">Nothing yet.</div>';}catch{}
73
+ setTimeout(poll,2000)}
74
+ poll();
75
+ </script></body></html>`;
76
+ }
@@ -0,0 +1,119 @@
1
+ // File transfer between this computer and the paired phone, over the bridge's own port.
2
+ // POST /files/in?name=… phone → computer (token header). Saved in the received folder.
3
+ // GET /files/out/<id> computer → phone (token header). The phone downloads a staged file.
4
+ // POST /files/drop?name=… drop page on this computer → staged and sent to the phone.
5
+ // GET /files/received what has arrived from the phone (this computer only).
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ import { pipeline } from 'node:stream/promises';
11
+
12
+ export const RECEIVED_DIR = process.env.AAB_RECEIVED || path.join(os.homedir(), 'Downloads', 'Agent Bridge');
13
+ const DROP_DIR = path.join(os.tmpdir(), 'agent-bridge-outbox');
14
+
15
+ /** A name that is safe on every OS and does not overwrite an existing file. */
16
+ export function uniquePath(dir, name) {
17
+ const clean = (path.basename(String(name || 'file')).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'file').slice(0, 180);
18
+ const ext = path.extname(clean);
19
+ const stem = clean.slice(0, clean.length - ext.length);
20
+ let p = path.join(dir, clean);
21
+ for (let i = 2; fs.existsSync(p); i++) p = path.join(dir, `${stem} (${i})${ext}`);
22
+ return p;
23
+ }
24
+
25
+ export class Files {
26
+ constructor({ log = () => {} } = {}) {
27
+ this.log = log;
28
+ this.staged = new Map(); // id -> { path, name, size, expires }
29
+ this.onDrop = null; // set by the bridge: (paths) => send them to the phone
30
+ }
31
+
32
+ /** Make files downloadable by the phone for a while; returns what to tell the phone. */
33
+ stage(paths) {
34
+ const now = Date.now();
35
+ for (const [id, f] of this.staged) if (f.expires < now) this.staged.delete(id);
36
+ return paths.map((p) => {
37
+ const st = fs.statSync(p);
38
+ if (!st.isFile()) throw new Error(`${p} is not a file`);
39
+ const id = crypto.randomBytes(9).toString('hex');
40
+ const f = { path: p, name: path.basename(p), size: st.size, expires: now + 30 * 60 * 1000 };
41
+ this.staged.set(id, f);
42
+ return { id, name: f.name, size: f.size };
43
+ });
44
+ }
45
+
46
+ received(limit = 30) {
47
+ if (!fs.existsSync(RECEIVED_DIR)) return { folder: RECEIVED_DIR, files: [] };
48
+ const files = fs.readdirSync(RECEIVED_DIR)
49
+ .map((name) => { const st = fs.statSync(path.join(RECEIVED_DIR, name)); return { name, size: st.size, time: st.mtimeMs, file: st.isFile() }; })
50
+ .filter((f) => f.file)
51
+ .sort((a, b) => b.time - a.time)
52
+ .slice(0, limit)
53
+ .map(({ file, ...f }) => f);
54
+ return { folder: RECEIVED_DIR, files };
55
+ }
56
+
57
+ /** Handles /files/* requests. authed: token header matched; local: request came from this computer. */
58
+ async handle(req, res, url, { authed, local }) {
59
+ const json = (code, body) => res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(body));
60
+ const name = url.searchParams.get('name') || 'file';
61
+
62
+ if (req.method === 'POST' && url.pathname === '/files/in') {
63
+ if (!authed) return json(401, { ok: false, error: 'bad token' });
64
+ fs.mkdirSync(RECEIVED_DIR, { recursive: true });
65
+ const dest = uniquePath(RECEIVED_DIR, name);
66
+ const part = dest + '.part';
67
+ try {
68
+ await pipeline(req, fs.createWriteStream(part));
69
+ fs.renameSync(part, dest);
70
+ } catch (err) {
71
+ fs.rmSync(part, { force: true });
72
+ return json(500, { ok: false, error: err.message });
73
+ }
74
+ const size = fs.statSync(dest).size;
75
+ this.log(`received ${path.basename(dest)} (${fmtSize(size)}) from the phone → ${dest}`);
76
+ return json(200, { ok: true, path: dest, name: path.basename(dest), size });
77
+ }
78
+
79
+ const out = url.pathname.match(/^\/files\/out\/([0-9a-f]+)$/);
80
+ if (req.method === 'GET' && out) {
81
+ if (!authed) return json(401, { ok: false, error: 'bad token' });
82
+ const f = this.staged.get(out[1]);
83
+ if (!f || !fs.existsSync(f.path)) return json(404, { ok: false, error: 'not staged' });
84
+ res.writeHead(200, {
85
+ 'content-type': 'application/octet-stream',
86
+ 'content-length': f.size,
87
+ 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(f.name)}`,
88
+ });
89
+ await pipeline(fs.createReadStream(f.path), res);
90
+ return;
91
+ }
92
+
93
+ if (req.method === 'POST' && url.pathname === '/files/drop') {
94
+ if (!local) return json(403, { ok: false, error: 'only reachable from this computer' });
95
+ fs.mkdirSync(DROP_DIR, { recursive: true });
96
+ const dest = uniquePath(DROP_DIR, name);
97
+ await pipeline(req, fs.createWriteStream(dest));
98
+ try {
99
+ const [result] = await this.onDrop([dest]);
100
+ return json(200, { ok: true, result });
101
+ } catch (err) {
102
+ return json(200, { ok: false, error: err.message });
103
+ }
104
+ }
105
+
106
+ if (req.method === 'GET' && url.pathname === '/files/received') {
107
+ if (!local) return json(403, { ok: false, error: 'only reachable from this computer' });
108
+ return json(200, { ok: true, result: this.received() });
109
+ }
110
+ json(404, { ok: false, error: 'not found' });
111
+ }
112
+ }
113
+
114
+ export function fmtSize(n) {
115
+ if (n < 1024) return `${n} B`;
116
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
117
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
118
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
119
+ }