@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 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."
@@ -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
- info = pickOne([...devices.values()], device, deviceLabel);
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 target = pickOne(
248
- await listCdpTargets(metro, signal),
249
- device,
250
- targetLabel
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
- async call(tool, args, timeoutMs) {
590
+ call(tool, args, timeoutMs) {
333
591
  const call = { id: newCallId(), tool, args };
334
592
  const reply = pending.wait(call.id, tool, timeoutMs);
335
- reply.catch(() => {
336
- });
337
- try {
338
- await evaluate(
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 (label, tool, ...given) => {
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")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
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,