@avasapp/agent-bridge 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -11
- package/dist/adapters/tanstack-query.cjs +3 -0
- package/dist/adapters/tanstack-query.d.cts +12 -3
- package/dist/{chunk-AJD3XTNT.js → chunk-3QIG7A5W.js} +292 -161
- package/dist/cli.js +1216 -22
- package/dist/client/index.cjs +255 -130
- package/dist/client/index.js +1 -1
- package/dist/runtime/index.cjs +102 -32
- package/dist/runtime/index.d.cts +2 -0
- package/package.json +3 -2
- package/skills/agent-bridge/SKILL.md +67 -252
- package/skills/agent-bridge/references/flows.md +66 -0
- package/skills/agent-bridge/references/large-data.md +38 -0
- package/skills/agent-bridge/references/network.md +34 -0
- package/skills/agent-bridge/references/realtime.md +20 -0
- package/skills/agent-bridge/references/restore.md +52 -0
- package/skills/agent-bridge/references/screen.md +38 -0
- package/skills/agent-bridge/references/state.md +48 -0
- package/skills/agent-bridge/references/webview.md +42 -0
- package/skills/agent-bridge/references/writing-scenarios.md +97 -0
package/README.md
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# @avasapp/agent-bridge
|
|
2
2
|
|
|
3
|
-
Let coding agents drive
|
|
3
|
+
Let coding agents drive any running Expo or React Native app directly. Seed data, flip flags, fake realtime messages, navigate and check the screen in **milliseconds**, without tapping through the app. It works alongside [agent-device](https://github.com/callstack/agent-device): agent-device opens the app and handles native UI, and agent-bridge handles everything inside the app.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
The same 19 checks on [the example app](example), at real speed: 22.4 s with agent-device alone, 1.4 s with agent-bridge. Most calls take a few milliseconds ([benchmarks](docs/benchmarks.md)).
|
|
8
8
|
|
|
9
|
-
Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro).
|
|
9
|
+
Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Nothing ships to users: every entry point compiles to an empty stub in release builds, and `agent-bridge assert-absent` checks a bundle in CI ([security](docs/security.md)).
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
|
+
```sh
|
|
14
|
+
npx @avasapp/agent-bridge init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Run it in the app. It installs the package, writes a dev-only `src/dev/agent-bridge.tsx` wired for the libraries it finds, adds a smoke flow and the agent skill, and prints the two lines to paste into your root component. Agents run it as `init --json`; see [the CLI](docs/cli.md#init).
|
|
18
|
+
|
|
19
|
+
### Manual setup
|
|
20
|
+
|
|
13
21
|
```sh
|
|
14
22
|
bun add -d @avasapp/agent-bridge # or npm / yarn / pnpm
|
|
15
23
|
```
|
|
@@ -74,17 +82,17 @@ Most flows need the app in a known state first, such as a user signed in locally
|
|
|
74
82
|
## From the agent
|
|
75
83
|
|
|
76
84
|
```sh
|
|
77
|
-
npx agent-bridge session start # hold one connection; call/tools/run reuse it
|
|
78
|
-
npx agent-bridge tools
|
|
79
|
-
npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
|
|
80
|
-
npx agent-bridge call query.pin '[["features"], {"beta": false}]'
|
|
81
|
-
npx agent-bridge call screen.press '"add-plant"'
|
|
82
|
-
npx agent-bridge call screen.waitFor '"Name is required"'
|
|
83
|
-
npx agent-bridge run flows/add-plant.mjs --strict # fail if the app logged an error
|
|
84
|
-
npx agent-bridge session stop # runs bridge.restore, then disconnects
|
|
85
|
+
npx @avasapp/agent-bridge session start # hold one connection; call/tools/run reuse it
|
|
86
|
+
npx @avasapp/agent-bridge tools
|
|
87
|
+
npx @avasapp/agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
|
|
88
|
+
npx @avasapp/agent-bridge call query.pin '[["features"], {"beta": false}]'
|
|
89
|
+
npx @avasapp/agent-bridge call screen.press '"add-plant"'
|
|
90
|
+
npx @avasapp/agent-bridge call screen.waitFor '"Name is required"'
|
|
91
|
+
npx @avasapp/agent-bridge run flows/add-plant.flow.mjs --strict # fail if the app logged an error
|
|
92
|
+
npx @avasapp/agent-bridge session stop # runs bridge.restore, then disconnects
|
|
85
93
|
```
|
|
86
94
|
|
|
87
|
-
Agents should run `./node_modules/.bin/agent-bridge`
|
|
95
|
+
Agents should run `./node_modules/.bin/agent-bridge` rather than `npx`, which is slower and can print npm warnings into their output. See [the CLI](docs/cli.md) for flows, batch mode, the REPL, sessions and the `connect()` client.
|
|
88
96
|
|
|
89
97
|
No device tool is needed. Pair one (such as agent-device) with the bridge for what it can't reach: system alerts, permission prompts, the keyboard, screenshots, and one real tap per flow.
|
|
90
98
|
|
|
@@ -222,6 +222,9 @@ function atPath(value, path) {
|
|
|
222
222
|
return current;
|
|
223
223
|
}
|
|
224
224
|
function queryTools(queryClient) {
|
|
225
|
+
return toolsFor(queryClient);
|
|
226
|
+
}
|
|
227
|
+
function toolsFor(queryClient) {
|
|
225
228
|
const cache = queryClient.getQueryCache();
|
|
226
229
|
const state = pinStateFor(queryClient);
|
|
227
230
|
const { pins, seeded } = state;
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { QueryClient } from '@tanstack/query-core';
|
|
2
1
|
import { T as Tools } from '../types-BmpykP1P.cjs';
|
|
3
2
|
|
|
3
|
+
/** A TanStack Query `QueryClient`, e.g. from `useQueryClient()`. */
|
|
4
|
+
type QueryClientLike = {
|
|
5
|
+
getQueryCache(): object;
|
|
6
|
+
getQueryData(queryKey: readonly unknown[]): unknown;
|
|
7
|
+
setQueryData(queryKey: readonly unknown[], updater: unknown): unknown;
|
|
8
|
+
invalidateQueries(filters?: object): Promise<void>;
|
|
9
|
+
refetchQueries(filters?: object): Promise<void>;
|
|
10
|
+
resetQueries(filters?: object): Promise<void>;
|
|
11
|
+
getDefaultOptions(): object;
|
|
12
|
+
};
|
|
4
13
|
/**
|
|
5
14
|
* Read and seed the TanStack Query cache. `query.pin` keeps a seed in place:
|
|
6
15
|
* when a refetch lands with real data, the seed is put back until unpinned.
|
|
7
16
|
*/
|
|
8
|
-
declare function queryTools(queryClient:
|
|
17
|
+
declare function queryTools(queryClient: QueryClientLike): Tools;
|
|
9
18
|
|
|
10
|
-
export { queryTools };
|
|
19
|
+
export { type QueryClientLike, queryTools };
|
|
@@ -35,11 +35,11 @@ async function listCdpTargets(metro, signal) {
|
|
|
35
35
|
);
|
|
36
36
|
return await res.json();
|
|
37
37
|
}
|
|
38
|
-
function pickOne(items, filter,
|
|
38
|
+
function pickOne(items, filter, label2) {
|
|
39
39
|
const matching = filter ? items.filter(
|
|
40
|
-
(item) =>
|
|
40
|
+
(item) => label2(item).toLowerCase().includes(filter.toLowerCase())
|
|
41
41
|
) : items;
|
|
42
|
-
const connected = items.map(
|
|
42
|
+
const connected = items.map(label2).join("; ") || "none";
|
|
43
43
|
if (matching.length === 0) {
|
|
44
44
|
throw new Error(
|
|
45
45
|
filter ? `No app matches "${filter}". Connected: ${connected}` : "No app is connected to Metro."
|
|
@@ -50,6 +50,15 @@ function pickOne(items, filter, label) {
|
|
|
50
50
|
`${matching.length} apps are connected; pick one with --device. Connected: ${connected}`
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
|
+
if (matching.length > 1) {
|
|
54
|
+
const exact = matching.filter(
|
|
55
|
+
(item) => label2(item).toLowerCase() === filter?.toLowerCase()
|
|
56
|
+
);
|
|
57
|
+
if (exact.length === 1) return exact[0];
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label2).join("; ")}`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
53
62
|
return matching[0];
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -67,6 +76,7 @@ function toAsciiJson(value) {
|
|
|
67
76
|
|
|
68
77
|
// src/client/connection.ts
|
|
69
78
|
import WebSocket from "ws";
|
|
79
|
+
var describePin = (pin) => `${pin.name} (${pin.platform}${pin.target ? `, ${pin.target}` : ""})`;
|
|
70
80
|
var OPEN_SOCKET_MS = 5e3;
|
|
71
81
|
var OpenGaveUp = class extends Error {
|
|
72
82
|
};
|
|
@@ -192,7 +202,7 @@ async function listExpoDevices(metro, discoveryMs = 600) {
|
|
|
192
202
|
ws.close();
|
|
193
203
|
return [...devices.values()];
|
|
194
204
|
}
|
|
195
|
-
async function connectExpo(metro, device, discoveryMs = 600, signal) {
|
|
205
|
+
async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
|
|
196
206
|
const { ws, devices, pending, send, announced } = await openBroadcast(
|
|
197
207
|
metro,
|
|
198
208
|
discoveryMs,
|
|
@@ -200,7 +210,13 @@ async function connectExpo(metro, device, discoveryMs = 600, signal) {
|
|
|
200
210
|
);
|
|
201
211
|
let info;
|
|
202
212
|
try {
|
|
203
|
-
|
|
213
|
+
const all = [...devices.values()];
|
|
214
|
+
const candidates = pin ? all.filter((d) => d.name === pin.name && d.platform === pin.platform) : all;
|
|
215
|
+
if (pin && !candidates.length)
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${describePin(pin)} is not connected; connected: ${all.map(deviceLabel).join("; ") || "none"}`
|
|
218
|
+
);
|
|
219
|
+
info = pickOne(candidates, device, deviceLabel);
|
|
204
220
|
} catch (error) {
|
|
205
221
|
ws.close();
|
|
206
222
|
throw error;
|
|
@@ -215,6 +231,7 @@ async function connectExpo(metro, device, discoveryMs = 600, signal) {
|
|
|
215
231
|
return {
|
|
216
232
|
transport: "expo",
|
|
217
233
|
device: info,
|
|
234
|
+
pin: { name: info.name, platform: info.platform },
|
|
218
235
|
call(tool, args, timeoutMs) {
|
|
219
236
|
if (superseded)
|
|
220
237
|
return Promise.resolve({
|
|
@@ -238,17 +255,247 @@ async function connectExpo(metro, device, discoveryMs = 600, signal) {
|
|
|
238
255
|
};
|
|
239
256
|
}
|
|
240
257
|
|
|
258
|
+
// src/client/session/state.ts
|
|
259
|
+
import { createHash } from "crypto";
|
|
260
|
+
import {
|
|
261
|
+
chmodSync,
|
|
262
|
+
existsSync,
|
|
263
|
+
lstatSync,
|
|
264
|
+
mkdirSync,
|
|
265
|
+
readFileSync,
|
|
266
|
+
readdirSync,
|
|
267
|
+
rmSync,
|
|
268
|
+
writeFileSync
|
|
269
|
+
} from "fs";
|
|
270
|
+
import { tmpdir, userInfo } from "os";
|
|
271
|
+
import { dirname, join, resolve } from "path";
|
|
272
|
+
function projectRoot(from = process.cwd()) {
|
|
273
|
+
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
274
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
275
|
+
if (dirname(dir) === dir) return resolve(from);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
var uid = () => process.getuid?.() ?? userInfo().username;
|
|
279
|
+
function privateDir(dir) {
|
|
280
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
281
|
+
const stat = lstatSync(dir);
|
|
282
|
+
if (!stat.isDirectory())
|
|
283
|
+
throw new Error(`${dir} is not a directory; remove it and retry`);
|
|
284
|
+
if (process.getuid && stat.uid !== process.getuid())
|
|
285
|
+
throw new Error(`${dir} belongs to another user; refusing to use it`);
|
|
286
|
+
if (process.platform !== "win32" && (stat.mode & 63) !== 0)
|
|
287
|
+
chmodSync(dir, 448);
|
|
288
|
+
return dir;
|
|
289
|
+
}
|
|
290
|
+
function stateDir() {
|
|
291
|
+
return privateDir(
|
|
292
|
+
process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? join(process.env.XDG_RUNTIME_DIR, "agent-bridge") : join(tmpdir(), `agent-bridge-${uid()}`))
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
var shortHash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
296
|
+
function socketPath(name, dir) {
|
|
297
|
+
if (process.platform === "win32")
|
|
298
|
+
return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
|
|
299
|
+
const path = join(dir, `${name}.sock`);
|
|
300
|
+
if (Buffer.byteLength(path) <= 100) return path;
|
|
301
|
+
const short = privateDir(`/tmp/agent-bridge-${uid()}`);
|
|
302
|
+
return join(short, `${shortHash(path)}.sock`);
|
|
303
|
+
}
|
|
304
|
+
var sessionFiles = (name, dir = stateDir()) => ({
|
|
305
|
+
state: join(dir, `${name}.json`),
|
|
306
|
+
error: join(dir, `${name}.error`),
|
|
307
|
+
log: join(dir, `${name}.log`),
|
|
308
|
+
socket: socketPath(name, dir)
|
|
309
|
+
});
|
|
310
|
+
function checkName(name) {
|
|
311
|
+
if (!/^[\w.-]{1,40}$/.test(name))
|
|
312
|
+
throw new Error(
|
|
313
|
+
`Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
|
|
314
|
+
);
|
|
315
|
+
return name;
|
|
316
|
+
}
|
|
317
|
+
function parseDuration(text) {
|
|
318
|
+
const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(text.trim());
|
|
319
|
+
if (!match) throw new Error(`Can't read duration "${text}"; try 15m or 90s`);
|
|
320
|
+
const units = { s: 1e3, m: 6e4, h: 36e5 };
|
|
321
|
+
return Math.round(Number(match[1]) * (units[match[2] ?? "ms"] ?? 1));
|
|
322
|
+
}
|
|
323
|
+
function formatDuration(ms) {
|
|
324
|
+
const s = Math.max(0, Math.round(ms / 1e3));
|
|
325
|
+
if (s < 60) return `${s}s`;
|
|
326
|
+
if (s < 3600)
|
|
327
|
+
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
328
|
+
return `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, "0")}m`;
|
|
329
|
+
}
|
|
330
|
+
function isAlive(pid) {
|
|
331
|
+
try {
|
|
332
|
+
process.kill(pid, 0);
|
|
333
|
+
return true;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return error.code === "EPERM";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function writePrivate(file, text) {
|
|
339
|
+
writeFileSync(file, text, { mode: 384 });
|
|
340
|
+
}
|
|
341
|
+
function removeSessionFiles(name, dir = stateDir()) {
|
|
342
|
+
const files = sessionFiles(name, dir);
|
|
343
|
+
rmSync(files.state, { force: true });
|
|
344
|
+
if (process.platform !== "win32") rmSync(files.socket, { force: true });
|
|
345
|
+
}
|
|
346
|
+
function readState(file) {
|
|
347
|
+
try {
|
|
348
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function listSessions(dir = stateDir()) {
|
|
354
|
+
const live = [];
|
|
355
|
+
for (const entry of readdirSync(dir)) {
|
|
356
|
+
if (!entry.endsWith(".json")) continue;
|
|
357
|
+
const state = readState(join(dir, entry));
|
|
358
|
+
if (!state) continue;
|
|
359
|
+
if (isAlive(state.pid)) live.push(state);
|
|
360
|
+
else removeSessionFiles(state.name, dir);
|
|
361
|
+
}
|
|
362
|
+
return live.sort((a, b) => a.startedAt - b.startedAt);
|
|
363
|
+
}
|
|
364
|
+
function readSession(name, dir = stateDir()) {
|
|
365
|
+
return listSessions(dir).find((s) => s.name === name) ?? null;
|
|
366
|
+
}
|
|
367
|
+
function pickSession(filter) {
|
|
368
|
+
if (filter.name) {
|
|
369
|
+
const named = readSession(checkName(filter.name));
|
|
370
|
+
if (!named) throw new Error(`No session named "${filter.name}" is running`);
|
|
371
|
+
return named;
|
|
372
|
+
}
|
|
373
|
+
const device = filter.device?.toLowerCase();
|
|
374
|
+
const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
|
|
375
|
+
const project = projectRoot();
|
|
376
|
+
const matching = listSessions().filter(
|
|
377
|
+
(s) => (explicit || s.project === project) && (!filter.metro || s.metro === filter.metro) && (!device || s.device.name.toLowerCase().includes(device) || s.device.deviceId.toLowerCase().includes(device)) && (!filter.transport || filter.transport === "auto" || filter.transport === s.transport)
|
|
378
|
+
);
|
|
379
|
+
if (matching.length > 1)
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Several sessions match (${matching.map((s) => `${s.name}: ${s.device.name} on ${s.metro}`).join("; ")}); pick one by name (--session, or --name for session stop/status), or narrow with --metro/--device`
|
|
382
|
+
);
|
|
383
|
+
return matching[0] ?? null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/client/metro-hint.ts
|
|
387
|
+
var range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
388
|
+
var probe = {
|
|
389
|
+
ports: [...range(8081, 8090), ...range(8100, 8199)],
|
|
390
|
+
eachMs: 300,
|
|
391
|
+
totalMs: 1e3
|
|
392
|
+
};
|
|
393
|
+
var hostOf = (metro) => {
|
|
394
|
+
const colon = metro.lastIndexOf(":");
|
|
395
|
+
return colon > 0 && !metro.endsWith("]") ? metro.slice(0, colon) : metro;
|
|
396
|
+
};
|
|
397
|
+
async function answers(metro, signal) {
|
|
398
|
+
try {
|
|
399
|
+
const res = await fetch(`http://${metro}/status`, { signal });
|
|
400
|
+
return (await res.text()).includes("packager-status:running");
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async function appsOn(metro, signal) {
|
|
406
|
+
try {
|
|
407
|
+
const res = await fetch(`http://${metro}/json/list`, { signal });
|
|
408
|
+
const targets = await res.json();
|
|
409
|
+
const names = targets.map(
|
|
410
|
+
(t) => `${t.title ?? "?"}${t.deviceName ? ` [${t.deviceName}]` : ""}`
|
|
411
|
+
);
|
|
412
|
+
return [...new Set(names)];
|
|
413
|
+
} catch {
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async function findMetros(metro) {
|
|
418
|
+
const host = hostOf(metro);
|
|
419
|
+
const candidates = new Set(probe.ports.map((port) => `${host}:${port}`));
|
|
420
|
+
try {
|
|
421
|
+
for (const s of listSessions()) candidates.add(s.metro);
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
candidates.delete(metro);
|
|
425
|
+
const deadline = AbortSignal.timeout(probe.totalMs);
|
|
426
|
+
const within2 = () => AbortSignal.any([deadline, AbortSignal.timeout(probe.eachMs)]);
|
|
427
|
+
const running = (await Promise.all(
|
|
428
|
+
[...candidates].map(async (m) => await answers(m, within2()) ? m : null)
|
|
429
|
+
)).filter((m) => m !== null);
|
|
430
|
+
return Promise.all(
|
|
431
|
+
running.map(async (m) => ({ metro: m, apps: await appsOn(m, within2()) }))
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
var label = (found) => `${found.metro} (${found.apps.length ? `${found.apps.length === 1 ? "app" : "apps"}: ${found.apps.join(", ")}` : "no app connected"})`;
|
|
435
|
+
var howToSet = (metro) => `Pass --metro ${metro} or set AGENT_BRIDGE_METRO=${metro}.`;
|
|
436
|
+
function elsewhere(found) {
|
|
437
|
+
const [only] = found;
|
|
438
|
+
if (found.length === 1 && only)
|
|
439
|
+
return `Metro is running on ${label(only)}. ${howToSet(only.metro)}`;
|
|
440
|
+
return `Metro is running on ${found.map(label).join("; ")}. ${howToSet("<host:port>")}`;
|
|
441
|
+
}
|
|
442
|
+
var errorText = (error) => {
|
|
443
|
+
if (!(error instanceof Error)) return String(error);
|
|
444
|
+
const code = error.code ?? "";
|
|
445
|
+
return `${error.message} ${code} ${error.cause ? errorText(error.cause) : ""}`;
|
|
446
|
+
};
|
|
447
|
+
var isRefused = (error) => /ECONNREFUSED|ConnectionRefused/.test(errorText(error));
|
|
448
|
+
var NO_APP = /No app is connected to Metro|No app matches/;
|
|
449
|
+
async function refusedMessage(metro) {
|
|
450
|
+
const found = await findMetros(metro);
|
|
451
|
+
if (found.length)
|
|
452
|
+
return `Nothing is listening on ${metro}. ${elsewhere(found)}`;
|
|
453
|
+
const host = hostOf(metro);
|
|
454
|
+
return `Nothing is listening on ${metro}, and no Metro answered on ${host} ports 8081-8090 or 8100-8199. Start Metro, or if it runs elsewhere, pass --metro <host:port> or set AGENT_BRIDGE_METRO=<host:port>.`;
|
|
455
|
+
}
|
|
456
|
+
async function noAppHint(metro, device) {
|
|
457
|
+
const causes = `Likely: the app isn't open or hasn't reloaded since the bridge was mounted, it uses another Metro${device ? `, or --device "${device}" doesn't match` : ""}.`;
|
|
458
|
+
const withApps = (await findMetros(metro)).filter((f) => f.apps.length);
|
|
459
|
+
return withApps.length ? `${causes} ${elsewhere(withApps)}` : causes;
|
|
460
|
+
}
|
|
461
|
+
async function refused(metro) {
|
|
462
|
+
try {
|
|
463
|
+
await fetch(`http://${metro}/status`, {
|
|
464
|
+
signal: AbortSignal.timeout(probe.eachMs)
|
|
465
|
+
});
|
|
466
|
+
return false;
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return isRefused(error);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function explainConnectError(error, metro, device) {
|
|
472
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
473
|
+
if (NO_APP.test(message))
|
|
474
|
+
return new Error(`${message}
|
|
475
|
+
${await noAppHint(metro, device)}`, {
|
|
476
|
+
cause: error
|
|
477
|
+
});
|
|
478
|
+
if (await refused(metro))
|
|
479
|
+
return new Error(await refusedMessage(metro), { cause: error });
|
|
480
|
+
return error;
|
|
481
|
+
}
|
|
482
|
+
async function assertListening(metro) {
|
|
483
|
+
if (await refused(metro)) throw new Error(await refusedMessage(metro));
|
|
484
|
+
}
|
|
485
|
+
|
|
241
486
|
// src/client/cdp.ts
|
|
242
487
|
import WebSocket2 from "ws";
|
|
243
488
|
var CannotEvaluate = class extends Error {
|
|
244
489
|
};
|
|
245
490
|
var targetLabel = (t) => `${t.title}${t.deviceName ? ` [${t.deviceName}]` : ""}`;
|
|
246
|
-
async function connectCdp(metro, device, signal) {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
491
|
+
async function connectCdp(metro, device, signal, pin) {
|
|
492
|
+
const targets = await listCdpTargets(metro, signal);
|
|
493
|
+
const candidates = pin?.target ? targets.filter((t) => targetLabel(t) === pin.target) : targets;
|
|
494
|
+
if (pin && !candidates.length)
|
|
495
|
+
throw new Error(
|
|
496
|
+
`${describePin(pin)} is not connected; connected: ${targets.map(targetLabel).join("; ") || "none"}`
|
|
497
|
+
);
|
|
498
|
+
const target = pickOne(candidates, device, targetLabel);
|
|
252
499
|
const hostUri = await expoHostUri(metro, signal);
|
|
253
500
|
const port = metro.split(":")[1] ?? "8081";
|
|
254
501
|
const origin = hostUri ? `http://${hostUri}` : `http://localhost:${port}`;
|
|
@@ -326,27 +573,34 @@ async function connectCdp(metro, device, signal) {
|
|
|
326
573
|
);
|
|
327
574
|
}
|
|
328
575
|
signal?.removeEventListener("abort", dropOnAbort);
|
|
576
|
+
if (pin && (info.name !== pin.name || info.platform !== pin.platform)) {
|
|
577
|
+
ws.terminate();
|
|
578
|
+
throw new Error(
|
|
579
|
+
`${describePin(pin)} is not connected; connected: ${targetLabel(target)} runs ${info.name} (${info.platform})`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
329
582
|
return {
|
|
330
583
|
transport: "cdp",
|
|
584
|
+
pin: {
|
|
585
|
+
name: info.name,
|
|
586
|
+
platform: info.platform,
|
|
587
|
+
target: targetLabel(target)
|
|
588
|
+
},
|
|
331
589
|
device: { ...info, name: `${info.name} (${targetLabel(target)})` },
|
|
332
|
-
|
|
590
|
+
call(tool, args, timeoutMs) {
|
|
333
591
|
const call = { id: newCallId(), tool, args };
|
|
334
592
|
const reply = pending.wait(call.id, tool, timeoutMs);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
`${CDP_GLOBAL}.dispatch(${JSON.stringify(toAsciiJson(call))})`
|
|
340
|
-
);
|
|
341
|
-
} catch (error) {
|
|
342
|
-
pending.settle({
|
|
593
|
+
evaluate(
|
|
594
|
+
`${CDP_GLOBAL}.dispatch(${JSON.stringify(toAsciiJson(call))})`
|
|
595
|
+
).catch(
|
|
596
|
+
(error) => pending.settle({
|
|
343
597
|
id: call.id,
|
|
344
598
|
from: info.deviceId,
|
|
345
599
|
ok: false,
|
|
346
600
|
error: String(error),
|
|
347
601
|
ms: 0
|
|
348
|
-
})
|
|
349
|
-
|
|
602
|
+
})
|
|
603
|
+
);
|
|
350
604
|
return reply;
|
|
351
605
|
},
|
|
352
606
|
close: () => ws.close()
|
|
@@ -389,7 +643,7 @@ async function openConnection(options = {}) {
|
|
|
389
643
|
try {
|
|
390
644
|
return await phase(
|
|
391
645
|
options,
|
|
392
|
-
(signal) => connectExpo(metro, options.device, void 0, signal)
|
|
646
|
+
(signal) => connectExpo(metro, options.device, void 0, signal, options.pin)
|
|
393
647
|
);
|
|
394
648
|
} catch (error) {
|
|
395
649
|
if (want === "expo" || options.signal?.aborted) throw error;
|
|
@@ -399,7 +653,7 @@ async function openConnection(options = {}) {
|
|
|
399
653
|
try {
|
|
400
654
|
return await phase(
|
|
401
655
|
options,
|
|
402
|
-
(signal) => connectCdp(metro, options.device, signal)
|
|
656
|
+
(signal) => connectCdp(metro, options.device, signal, options.pin)
|
|
403
657
|
);
|
|
404
658
|
} catch (error) {
|
|
405
659
|
const expoNote = expoError ? ` (Expo socket: ${String(expoError)})` : "";
|
|
@@ -476,7 +730,7 @@ async function runFlow(bridge, flow, options = {}) {
|
|
|
476
730
|
}
|
|
477
731
|
};
|
|
478
732
|
const call = async (tool, ...args) => (await timed(tool, ...args)).value;
|
|
479
|
-
const step = async (
|
|
733
|
+
const step = async (label2, tool, ...given) => {
|
|
480
734
|
const args = given.length === 1 && Array.isArray(given[0]) ? given[0] : given;
|
|
481
735
|
const { value, ms, logs, notice } = await bridge.timed(tool, ...args).catch((error) => {
|
|
482
736
|
report(failedLogs(error), failedNotice(error));
|
|
@@ -484,7 +738,7 @@ async function runFlow(bridge, flow, options = {}) {
|
|
|
484
738
|
});
|
|
485
739
|
total += ms;
|
|
486
740
|
print(
|
|
487
|
-
`${String(++n).padStart(2, "0")} ${
|
|
741
|
+
`${String(++n).padStart(2, "0")} ${label2.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
|
|
488
742
|
);
|
|
489
743
|
report(logs, notice);
|
|
490
744
|
return value;
|
|
@@ -536,134 +790,6 @@ import { createInterface } from "readline";
|
|
|
536
790
|
var encodeLine = (message) => `${JSON.stringify(message).replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`)}
|
|
537
791
|
`;
|
|
538
792
|
|
|
539
|
-
// src/client/session/state.ts
|
|
540
|
-
import { createHash } from "crypto";
|
|
541
|
-
import {
|
|
542
|
-
chmodSync,
|
|
543
|
-
existsSync,
|
|
544
|
-
lstatSync,
|
|
545
|
-
mkdirSync,
|
|
546
|
-
readFileSync,
|
|
547
|
-
readdirSync,
|
|
548
|
-
rmSync,
|
|
549
|
-
writeFileSync
|
|
550
|
-
} from "fs";
|
|
551
|
-
import { tmpdir, userInfo } from "os";
|
|
552
|
-
import { dirname, join, resolve } from "path";
|
|
553
|
-
function projectRoot(from = process.cwd()) {
|
|
554
|
-
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
555
|
-
if (existsSync(join(dir, "package.json"))) return dir;
|
|
556
|
-
if (dirname(dir) === dir) return resolve(from);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
var uid = () => process.getuid?.() ?? userInfo().username;
|
|
560
|
-
function privateDir(dir) {
|
|
561
|
-
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
562
|
-
const stat = lstatSync(dir);
|
|
563
|
-
if (!stat.isDirectory())
|
|
564
|
-
throw new Error(`${dir} is not a directory; remove it and retry`);
|
|
565
|
-
if (process.getuid && stat.uid !== process.getuid())
|
|
566
|
-
throw new Error(`${dir} belongs to another user; refusing to use it`);
|
|
567
|
-
if (process.platform !== "win32" && (stat.mode & 63) !== 0)
|
|
568
|
-
chmodSync(dir, 448);
|
|
569
|
-
return dir;
|
|
570
|
-
}
|
|
571
|
-
function stateDir() {
|
|
572
|
-
return privateDir(
|
|
573
|
-
process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? join(process.env.XDG_RUNTIME_DIR, "agent-bridge") : join(tmpdir(), `agent-bridge-${uid()}`))
|
|
574
|
-
);
|
|
575
|
-
}
|
|
576
|
-
var shortHash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
577
|
-
function socketPath(name, dir) {
|
|
578
|
-
if (process.platform === "win32")
|
|
579
|
-
return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
|
|
580
|
-
const path = join(dir, `${name}.sock`);
|
|
581
|
-
if (Buffer.byteLength(path) <= 100) return path;
|
|
582
|
-
const short = privateDir(`/tmp/agent-bridge-${uid()}`);
|
|
583
|
-
return join(short, `${shortHash(path)}.sock`);
|
|
584
|
-
}
|
|
585
|
-
var sessionFiles = (name, dir = stateDir()) => ({
|
|
586
|
-
state: join(dir, `${name}.json`),
|
|
587
|
-
error: join(dir, `${name}.error`),
|
|
588
|
-
log: join(dir, `${name}.log`),
|
|
589
|
-
socket: socketPath(name, dir)
|
|
590
|
-
});
|
|
591
|
-
function checkName(name) {
|
|
592
|
-
if (!/^[\w.-]{1,40}$/.test(name))
|
|
593
|
-
throw new Error(
|
|
594
|
-
`Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
|
|
595
|
-
);
|
|
596
|
-
return name;
|
|
597
|
-
}
|
|
598
|
-
function parseDuration(text) {
|
|
599
|
-
const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(text.trim());
|
|
600
|
-
if (!match) throw new Error(`Can't read duration "${text}"; try 15m or 90s`);
|
|
601
|
-
const units = { s: 1e3, m: 6e4, h: 36e5 };
|
|
602
|
-
return Math.round(Number(match[1]) * (units[match[2] ?? "ms"] ?? 1));
|
|
603
|
-
}
|
|
604
|
-
function formatDuration(ms) {
|
|
605
|
-
const s = Math.max(0, Math.round(ms / 1e3));
|
|
606
|
-
if (s < 60) return `${s}s`;
|
|
607
|
-
if (s < 3600)
|
|
608
|
-
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
609
|
-
return `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, "0")}m`;
|
|
610
|
-
}
|
|
611
|
-
function isAlive(pid) {
|
|
612
|
-
try {
|
|
613
|
-
process.kill(pid, 0);
|
|
614
|
-
return true;
|
|
615
|
-
} catch (error) {
|
|
616
|
-
return error.code === "EPERM";
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
function writePrivate(file, text) {
|
|
620
|
-
writeFileSync(file, text, { mode: 384 });
|
|
621
|
-
}
|
|
622
|
-
function removeSessionFiles(name, dir = stateDir()) {
|
|
623
|
-
const files = sessionFiles(name, dir);
|
|
624
|
-
rmSync(files.state, { force: true });
|
|
625
|
-
if (process.platform !== "win32") rmSync(files.socket, { force: true });
|
|
626
|
-
}
|
|
627
|
-
function readState(file) {
|
|
628
|
-
try {
|
|
629
|
-
return JSON.parse(readFileSync(file, "utf8"));
|
|
630
|
-
} catch {
|
|
631
|
-
return null;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
function listSessions(dir = stateDir()) {
|
|
635
|
-
const live = [];
|
|
636
|
-
for (const entry of readdirSync(dir)) {
|
|
637
|
-
if (!entry.endsWith(".json")) continue;
|
|
638
|
-
const state = readState(join(dir, entry));
|
|
639
|
-
if (!state) continue;
|
|
640
|
-
if (isAlive(state.pid)) live.push(state);
|
|
641
|
-
else removeSessionFiles(state.name, dir);
|
|
642
|
-
}
|
|
643
|
-
return live.sort((a, b) => a.startedAt - b.startedAt);
|
|
644
|
-
}
|
|
645
|
-
function readSession(name, dir = stateDir()) {
|
|
646
|
-
return listSessions(dir).find((s) => s.name === name) ?? null;
|
|
647
|
-
}
|
|
648
|
-
function pickSession(filter) {
|
|
649
|
-
if (filter.name) {
|
|
650
|
-
const named = readSession(checkName(filter.name));
|
|
651
|
-
if (!named) throw new Error(`No session named "${filter.name}" is running`);
|
|
652
|
-
return named;
|
|
653
|
-
}
|
|
654
|
-
const device = filter.device?.toLowerCase();
|
|
655
|
-
const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
|
|
656
|
-
const project = projectRoot();
|
|
657
|
-
const matching = listSessions().filter(
|
|
658
|
-
(s) => (explicit || s.project === project) && (!filter.metro || s.metro === filter.metro) && (!device || s.device.name.toLowerCase().includes(device) || s.device.deviceId.toLowerCase().includes(device)) && (!filter.transport || filter.transport === "auto" || filter.transport === s.transport)
|
|
659
|
-
);
|
|
660
|
-
if (matching.length > 1)
|
|
661
|
-
throw new Error(
|
|
662
|
-
`Several sessions match (${matching.map((s) => `${s.name}: ${s.device.name} on ${s.metro}`).join("; ")}); pick one by name (--session, or --name for session stop/status), or narrow with --metro/--device`
|
|
663
|
-
);
|
|
664
|
-
return matching[0] ?? null;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
793
|
// src/client/session/client.ts
|
|
668
794
|
function openSessionLink(state) {
|
|
669
795
|
return new Promise((resolve2, reject) => {
|
|
@@ -793,7 +919,9 @@ Warning: ${notice}` : ""}`);
|
|
|
793
919
|
};
|
|
794
920
|
async function connect(options = {}) {
|
|
795
921
|
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
796
|
-
const conn = await openConnection(options)
|
|
922
|
+
const conn = await openConnection(options).catch(async (error) => {
|
|
923
|
+
throw await explainConnectError(error, metroHost(options.metro), options.device);
|
|
924
|
+
});
|
|
797
925
|
const timed = async (tool, ...args) => {
|
|
798
926
|
const t0 = performance.now();
|
|
799
927
|
const result = await conn.call(tool, args, timeoutMs);
|
|
@@ -835,12 +963,6 @@ export {
|
|
|
835
963
|
explicitMetro,
|
|
836
964
|
metroHost,
|
|
837
965
|
RUNTIME_MARKER,
|
|
838
|
-
openConnection,
|
|
839
|
-
logLine,
|
|
840
|
-
flowScenarios,
|
|
841
|
-
parseScenarioFlag,
|
|
842
|
-
runFlow,
|
|
843
|
-
encodeLine,
|
|
844
966
|
projectRoot,
|
|
845
967
|
stateDir,
|
|
846
968
|
sessionFiles,
|
|
@@ -853,6 +975,15 @@ export {
|
|
|
853
975
|
listSessions,
|
|
854
976
|
readSession,
|
|
855
977
|
pickSession,
|
|
978
|
+
noAppHint,
|
|
979
|
+
explainConnectError,
|
|
980
|
+
assertListening,
|
|
981
|
+
openConnection,
|
|
982
|
+
logLine,
|
|
983
|
+
flowScenarios,
|
|
984
|
+
parseScenarioFlag,
|
|
985
|
+
runFlow,
|
|
986
|
+
encodeLine,
|
|
856
987
|
sessionRequest,
|
|
857
988
|
connectSession,
|
|
858
989
|
AgentBridgeCallError,
|