@avasapp/agent-bridge 0.4.2 → 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-TA2OLPPT.js → chunk-3QIG7A5W.js} +247 -142
- package/dist/cli.js +1198 -12
- package/dist/client/index.cjs +210 -111
- package/dist/client/index.js +1 -1
- package/dist/runtime/index.cjs +69 -16
- package/dist/runtime/index.d.cts +2 -0
- package/package.json +3 -2
- package/skills/agent-bridge/SKILL.md +32 -15
- package/skills/agent-bridge/references/flows.md +6 -5
- package/skills/agent-bridge/references/large-data.md +6 -6
- package/skills/agent-bridge/references/network.md +7 -7
- package/skills/agent-bridge/references/realtime.md +5 -5
- package/skills/agent-bridge/references/restore.md +4 -4
- package/skills/agent-bridge/references/screen.md +18 -10
- package/skills/agent-bridge/references/state.md +11 -11
- package/skills/agent-bridge/references/webview.md +9 -9
- package/skills/agent-bridge/references/writing-scenarios.md +3 -3
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."
|
|
@@ -52,11 +52,11 @@ function pickOne(items, filter, label) {
|
|
|
52
52
|
}
|
|
53
53
|
if (matching.length > 1) {
|
|
54
54
|
const exact = matching.filter(
|
|
55
|
-
(item) =>
|
|
55
|
+
(item) => label2(item).toLowerCase() === filter?.toLowerCase()
|
|
56
56
|
);
|
|
57
57
|
if (exact.length === 1) return exact[0];
|
|
58
58
|
throw new Error(
|
|
59
|
-
`${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(
|
|
59
|
+
`${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label2).join("; ")}`
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
return matching[0];
|
|
@@ -255,6 +255,234 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
|
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
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
|
+
|
|
258
486
|
// src/client/cdp.ts
|
|
259
487
|
import WebSocket2 from "ws";
|
|
260
488
|
var CannotEvaluate = class extends Error {
|
|
@@ -502,7 +730,7 @@ async function runFlow(bridge, flow, options = {}) {
|
|
|
502
730
|
}
|
|
503
731
|
};
|
|
504
732
|
const call = async (tool, ...args) => (await timed(tool, ...args)).value;
|
|
505
|
-
const step = async (
|
|
733
|
+
const step = async (label2, tool, ...given) => {
|
|
506
734
|
const args = given.length === 1 && Array.isArray(given[0]) ? given[0] : given;
|
|
507
735
|
const { value, ms, logs, notice } = await bridge.timed(tool, ...args).catch((error) => {
|
|
508
736
|
report(failedLogs(error), failedNotice(error));
|
|
@@ -510,7 +738,7 @@ async function runFlow(bridge, flow, options = {}) {
|
|
|
510
738
|
});
|
|
511
739
|
total += ms;
|
|
512
740
|
print(
|
|
513
|
-
`${String(++n).padStart(2, "0")} ${
|
|
741
|
+
`${String(++n).padStart(2, "0")} ${label2.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
|
|
514
742
|
);
|
|
515
743
|
report(logs, notice);
|
|
516
744
|
return value;
|
|
@@ -562,134 +790,6 @@ import { createInterface } from "readline";
|
|
|
562
790
|
var encodeLine = (message) => `${JSON.stringify(message).replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`)}
|
|
563
791
|
`;
|
|
564
792
|
|
|
565
|
-
// src/client/session/state.ts
|
|
566
|
-
import { createHash } from "crypto";
|
|
567
|
-
import {
|
|
568
|
-
chmodSync,
|
|
569
|
-
existsSync,
|
|
570
|
-
lstatSync,
|
|
571
|
-
mkdirSync,
|
|
572
|
-
readFileSync,
|
|
573
|
-
readdirSync,
|
|
574
|
-
rmSync,
|
|
575
|
-
writeFileSync
|
|
576
|
-
} from "fs";
|
|
577
|
-
import { tmpdir, userInfo } from "os";
|
|
578
|
-
import { dirname, join, resolve } from "path";
|
|
579
|
-
function projectRoot(from = process.cwd()) {
|
|
580
|
-
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
581
|
-
if (existsSync(join(dir, "package.json"))) return dir;
|
|
582
|
-
if (dirname(dir) === dir) return resolve(from);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
var uid = () => process.getuid?.() ?? userInfo().username;
|
|
586
|
-
function privateDir(dir) {
|
|
587
|
-
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
588
|
-
const stat = lstatSync(dir);
|
|
589
|
-
if (!stat.isDirectory())
|
|
590
|
-
throw new Error(`${dir} is not a directory; remove it and retry`);
|
|
591
|
-
if (process.getuid && stat.uid !== process.getuid())
|
|
592
|
-
throw new Error(`${dir} belongs to another user; refusing to use it`);
|
|
593
|
-
if (process.platform !== "win32" && (stat.mode & 63) !== 0)
|
|
594
|
-
chmodSync(dir, 448);
|
|
595
|
-
return dir;
|
|
596
|
-
}
|
|
597
|
-
function stateDir() {
|
|
598
|
-
return privateDir(
|
|
599
|
-
process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? join(process.env.XDG_RUNTIME_DIR, "agent-bridge") : join(tmpdir(), `agent-bridge-${uid()}`))
|
|
600
|
-
);
|
|
601
|
-
}
|
|
602
|
-
var shortHash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
603
|
-
function socketPath(name, dir) {
|
|
604
|
-
if (process.platform === "win32")
|
|
605
|
-
return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
|
|
606
|
-
const path = join(dir, `${name}.sock`);
|
|
607
|
-
if (Buffer.byteLength(path) <= 100) return path;
|
|
608
|
-
const short = privateDir(`/tmp/agent-bridge-${uid()}`);
|
|
609
|
-
return join(short, `${shortHash(path)}.sock`);
|
|
610
|
-
}
|
|
611
|
-
var sessionFiles = (name, dir = stateDir()) => ({
|
|
612
|
-
state: join(dir, `${name}.json`),
|
|
613
|
-
error: join(dir, `${name}.error`),
|
|
614
|
-
log: join(dir, `${name}.log`),
|
|
615
|
-
socket: socketPath(name, dir)
|
|
616
|
-
});
|
|
617
|
-
function checkName(name) {
|
|
618
|
-
if (!/^[\w.-]{1,40}$/.test(name))
|
|
619
|
-
throw new Error(
|
|
620
|
-
`Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
|
|
621
|
-
);
|
|
622
|
-
return name;
|
|
623
|
-
}
|
|
624
|
-
function parseDuration(text) {
|
|
625
|
-
const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(text.trim());
|
|
626
|
-
if (!match) throw new Error(`Can't read duration "${text}"; try 15m or 90s`);
|
|
627
|
-
const units = { s: 1e3, m: 6e4, h: 36e5 };
|
|
628
|
-
return Math.round(Number(match[1]) * (units[match[2] ?? "ms"] ?? 1));
|
|
629
|
-
}
|
|
630
|
-
function formatDuration(ms) {
|
|
631
|
-
const s = Math.max(0, Math.round(ms / 1e3));
|
|
632
|
-
if (s < 60) return `${s}s`;
|
|
633
|
-
if (s < 3600)
|
|
634
|
-
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
635
|
-
return `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, "0")}m`;
|
|
636
|
-
}
|
|
637
|
-
function isAlive(pid) {
|
|
638
|
-
try {
|
|
639
|
-
process.kill(pid, 0);
|
|
640
|
-
return true;
|
|
641
|
-
} catch (error) {
|
|
642
|
-
return error.code === "EPERM";
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
function writePrivate(file, text) {
|
|
646
|
-
writeFileSync(file, text, { mode: 384 });
|
|
647
|
-
}
|
|
648
|
-
function removeSessionFiles(name, dir = stateDir()) {
|
|
649
|
-
const files = sessionFiles(name, dir);
|
|
650
|
-
rmSync(files.state, { force: true });
|
|
651
|
-
if (process.platform !== "win32") rmSync(files.socket, { force: true });
|
|
652
|
-
}
|
|
653
|
-
function readState(file) {
|
|
654
|
-
try {
|
|
655
|
-
return JSON.parse(readFileSync(file, "utf8"));
|
|
656
|
-
} catch {
|
|
657
|
-
return null;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
function listSessions(dir = stateDir()) {
|
|
661
|
-
const live = [];
|
|
662
|
-
for (const entry of readdirSync(dir)) {
|
|
663
|
-
if (!entry.endsWith(".json")) continue;
|
|
664
|
-
const state = readState(join(dir, entry));
|
|
665
|
-
if (!state) continue;
|
|
666
|
-
if (isAlive(state.pid)) live.push(state);
|
|
667
|
-
else removeSessionFiles(state.name, dir);
|
|
668
|
-
}
|
|
669
|
-
return live.sort((a, b) => a.startedAt - b.startedAt);
|
|
670
|
-
}
|
|
671
|
-
function readSession(name, dir = stateDir()) {
|
|
672
|
-
return listSessions(dir).find((s) => s.name === name) ?? null;
|
|
673
|
-
}
|
|
674
|
-
function pickSession(filter) {
|
|
675
|
-
if (filter.name) {
|
|
676
|
-
const named = readSession(checkName(filter.name));
|
|
677
|
-
if (!named) throw new Error(`No session named "${filter.name}" is running`);
|
|
678
|
-
return named;
|
|
679
|
-
}
|
|
680
|
-
const device = filter.device?.toLowerCase();
|
|
681
|
-
const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
|
|
682
|
-
const project = projectRoot();
|
|
683
|
-
const matching = listSessions().filter(
|
|
684
|
-
(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)
|
|
685
|
-
);
|
|
686
|
-
if (matching.length > 1)
|
|
687
|
-
throw new Error(
|
|
688
|
-
`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`
|
|
689
|
-
);
|
|
690
|
-
return matching[0] ?? null;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
793
|
// src/client/session/client.ts
|
|
694
794
|
function openSessionLink(state) {
|
|
695
795
|
return new Promise((resolve2, reject) => {
|
|
@@ -819,7 +919,9 @@ Warning: ${notice}` : ""}`);
|
|
|
819
919
|
};
|
|
820
920
|
async function connect(options = {}) {
|
|
821
921
|
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
822
|
-
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
|
+
});
|
|
823
925
|
const timed = async (tool, ...args) => {
|
|
824
926
|
const t0 = performance.now();
|
|
825
927
|
const result = await conn.call(tool, args, timeoutMs);
|
|
@@ -861,12 +963,6 @@ export {
|
|
|
861
963
|
explicitMetro,
|
|
862
964
|
metroHost,
|
|
863
965
|
RUNTIME_MARKER,
|
|
864
|
-
openConnection,
|
|
865
|
-
logLine,
|
|
866
|
-
flowScenarios,
|
|
867
|
-
parseScenarioFlag,
|
|
868
|
-
runFlow,
|
|
869
|
-
encodeLine,
|
|
870
966
|
projectRoot,
|
|
871
967
|
stateDir,
|
|
872
968
|
sessionFiles,
|
|
@@ -879,6 +975,15 @@ export {
|
|
|
879
975
|
listSessions,
|
|
880
976
|
readSession,
|
|
881
977
|
pickSession,
|
|
978
|
+
noAppHint,
|
|
979
|
+
explainConnectError,
|
|
980
|
+
assertListening,
|
|
981
|
+
openConnection,
|
|
982
|
+
logLine,
|
|
983
|
+
flowScenarios,
|
|
984
|
+
parseScenarioFlag,
|
|
985
|
+
runFlow,
|
|
986
|
+
encodeLine,
|
|
882
987
|
sessionRequest,
|
|
883
988
|
connectSession,
|
|
884
989
|
AgentBridgeCallError,
|