@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 CHANGED
@@ -1,15 +1,23 @@
1
1
  # @avasapp/agent-bridge
2
2
 
3
- Let coding agents drive a running 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.
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
  ![The same checks with agent-device alone (22.4 s) and with agent-bridge (1.4 s): hide a tab, turn on dark mode, fill in a form, seed data, fake a live message and undo it all](example/media/demo.gif)
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). Dev builds only: release builds get empty stubs.
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` (or `bunx 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.
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: QueryClient): Tools;
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, label) {
38
+ function pickOne(items, filter, label2) {
39
39
  const matching = filter ? items.filter(
40
- (item) => label(item).toLowerCase().includes(filter.toLowerCase())
40
+ (item) => label2(item).toLowerCase().includes(filter.toLowerCase())
41
41
  ) : items;
42
- const connected = items.map(label).join("; ") || "none";
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) => label(item).toLowerCase() === filter?.toLowerCase()
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(label).join("; ")}`
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 (label, tool, ...given) => {
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")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
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,