@avasapp/agent-bridge 0.4.2 → 0.6.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.
Files changed (65) hide show
  1. package/README.md +36 -14
  2. package/dist/adapters/ably.cjs +79 -3
  3. package/dist/adapters/ably.d.cts +2 -1
  4. package/dist/adapters/async-storage.cjs +158 -0
  5. package/dist/adapters/async-storage.d.cts +14 -0
  6. package/dist/adapters/device.cjs +1110 -0
  7. package/dist/adapters/device.d.cts +49 -0
  8. package/dist/adapters/expo-router.cjs +112 -6
  9. package/dist/adapters/expo-router.d.cts +1 -1
  10. package/dist/adapters/jotai.cjs +266 -0
  11. package/dist/adapters/jotai.d.cts +19 -0
  12. package/dist/adapters/react-native-mmkv.cjs +80 -0
  13. package/dist/adapters/react-native-mmkv.d.cts +3 -1
  14. package/dist/adapters/react-native-webview.d.cts +1 -1
  15. package/dist/adapters/react-navigation.cjs +293 -0
  16. package/dist/adapters/react-navigation.d.cts +55 -0
  17. package/dist/adapters/redux.cjs +322 -0
  18. package/dist/adapters/redux.d.cts +28 -0
  19. package/dist/adapters/secure-store.cjs +169 -0
  20. package/dist/adapters/secure-store.d.cts +28 -0
  21. package/dist/adapters/socket-io.cjs +77 -3
  22. package/dist/adapters/socket-io.d.cts +2 -1
  23. package/dist/adapters/tanstack-query.cjs +3 -0
  24. package/dist/adapters/tanstack-query.d.cts +13 -4
  25. package/dist/adapters/zustand.cjs +193 -22
  26. package/dist/adapters/zustand.d.cts +11 -15
  27. package/dist/{chunk-TA2OLPPT.js → chunk-Y6QFU5ON.js} +477 -165
  28. package/dist/cli.js +2370 -103
  29. package/dist/client/index.cjs +441 -136
  30. package/dist/client/index.d.ts +67 -4
  31. package/dist/client/index.js +5 -3
  32. package/dist/expo/index.d.cts +1 -1
  33. package/dist/network/index.cjs +320 -73
  34. package/dist/network/index.d.cts +18 -3
  35. package/dist/noop/async-storage.cjs +26 -0
  36. package/dist/noop/device.cjs +26 -0
  37. package/dist/noop/jotai.cjs +26 -0
  38. package/dist/noop/react-navigation.cjs +26 -0
  39. package/dist/noop/realtime.cjs +6 -1
  40. package/dist/noop/redux.cjs +28 -0
  41. package/dist/noop/secure-store.cjs +26 -0
  42. package/dist/realtime/index.cjs +71 -4
  43. package/dist/realtime/index.d.cts +21 -2
  44. package/dist/runtime/index.cjs +838 -128
  45. package/dist/runtime/index.d.cts +4 -2
  46. package/dist/store-common-j0cHHvBr.d.cts +23 -0
  47. package/dist/{types-BmpykP1P.d.cts → types-qlwdcGjt.d.cts} +22 -0
  48. package/entries/async-storage.cjs +9 -0
  49. package/entries/device.cjs +9 -0
  50. package/entries/jotai.cjs +9 -0
  51. package/entries/react-navigation.cjs +9 -0
  52. package/entries/redux.cjs +9 -0
  53. package/entries/secure-store.cjs +9 -0
  54. package/package.json +34 -2
  55. package/skills/agent-bridge/SKILL.md +70 -22
  56. package/skills/agent-bridge/references/device.md +21 -0
  57. package/skills/agent-bridge/references/flows.md +19 -8
  58. package/skills/agent-bridge/references/large-data.md +6 -6
  59. package/skills/agent-bridge/references/network.md +25 -7
  60. package/skills/agent-bridge/references/realtime.md +5 -5
  61. package/skills/agent-bridge/references/restore.md +25 -7
  62. package/skills/agent-bridge/references/screen.md +25 -11
  63. package/skills/agent-bridge/references/state.md +31 -11
  64. package/skills/agent-bridge/references/webview.md +9 -9
  65. package/skills/agent-bridge/references/writing-scenarios.md +3 -3
@@ -35,10 +35,26 @@ __export(client_exports, {
35
35
  connectSession: () => connectSession,
36
36
  listDevices: () => listDevices,
37
37
  listSessions: () => listSessions,
38
- runFlow: () => runFlow
38
+ runFlow: () => runFlow,
39
+ runFlows: () => runFlows
39
40
  });
40
41
  module.exports = __toCommonJS(client_exports);
41
42
 
43
+ // src/client/call-timeout.ts
44
+ var WAITERS = {
45
+ "screen.waitFor": 2e3,
46
+ // The page helper adds 5 s to timeoutMs before it gives up.
47
+ "webview.waitFor": 7e3
48
+ };
49
+ var MAX_MS = 10 * 6e4;
50
+ function callTimeout(base, tool, args) {
51
+ const margin = WAITERS[tool];
52
+ const t = args[1]?.timeoutMs;
53
+ if (margin === void 0 || typeof t !== "number" || !(t > 0) || !Number.isFinite(t))
54
+ return base;
55
+ return Math.max(base, Math.min(t + margin, MAX_MS));
56
+ }
57
+
42
58
  // src/client/discover.ts
43
59
  var within = (ms, signal) => signal ? AbortSignal.any([AbortSignal.timeout(ms), signal]) : AbortSignal.timeout(ms);
44
60
  var DEFAULT_METRO = "localhost:8081";
@@ -76,11 +92,11 @@ async function listCdpTargets(metro, signal) {
76
92
  );
77
93
  return await res.json();
78
94
  }
79
- function pickOne(items, filter, label) {
95
+ function pickOne(items, filter, label2) {
80
96
  const matching = filter ? items.filter(
81
- (item) => label(item).toLowerCase().includes(filter.toLowerCase())
97
+ (item) => label2(item).toLowerCase().includes(filter.toLowerCase())
82
98
  ) : items;
83
- const connected = items.map(label).join("; ") || "none";
99
+ const connected = items.map(label2).join("; ") || "none";
84
100
  if (matching.length === 0) {
85
101
  throw new Error(
86
102
  filter ? `No app matches "${filter}". Connected: ${connected}` : "No app is connected to Metro."
@@ -93,11 +109,11 @@ function pickOne(items, filter, label) {
93
109
  }
94
110
  if (matching.length > 1) {
95
111
  const exact = matching.filter(
96
- (item) => label(item).toLowerCase() === filter?.toLowerCase()
112
+ (item) => label2(item).toLowerCase() === filter?.toLowerCase()
97
113
  );
98
114
  if (exact.length === 1) return exact[0];
99
115
  throw new Error(
100
- `${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label).join("; ")}`
116
+ `${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label2).join("; ")}`
101
117
  );
102
118
  }
103
119
  return matching[0];
@@ -272,7 +288,7 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
272
288
  transport: "expo",
273
289
  device: info,
274
290
  pin: { name: info.name, platform: info.platform },
275
- call(tool, args, timeoutMs) {
291
+ call(tool, args, timeoutMs, extra) {
276
292
  if (superseded)
277
293
  return Promise.resolve({
278
294
  id: "",
@@ -285,7 +301,8 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
285
301
  id: newCallId(),
286
302
  tool,
287
303
  args,
288
- to: info.deviceId
304
+ to: info.deviceId,
305
+ ...extra
289
306
  };
290
307
  const reply = pending.wait(call.id, tool, timeoutMs);
291
308
  send("call", call);
@@ -295,6 +312,219 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
295
312
  };
296
313
  }
297
314
 
315
+ // src/client/session/state.ts
316
+ var import_node_crypto = require("crypto");
317
+ var import_node_fs = require("fs");
318
+ var import_node_os = require("os");
319
+ var import_node_path = require("path");
320
+ function projectRoot(from = process.cwd()) {
321
+ for (let dir = (0, import_node_path.resolve)(from); ; dir = (0, import_node_path.dirname)(dir)) {
322
+ if ((0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "package.json"))) return dir;
323
+ if ((0, import_node_path.dirname)(dir) === dir) return (0, import_node_path.resolve)(from);
324
+ }
325
+ }
326
+ var isPackage = (dir) => (0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "package.json"));
327
+ function inProject(session, project) {
328
+ if (!session) return false;
329
+ if (session === project) return true;
330
+ if (!session.startsWith(project + import_node_path.sep) || !isPackage(project)) return false;
331
+ for (let dir = session; dir !== project; dir = (0, import_node_path.dirname)(dir)) {
332
+ if ((0, import_node_fs.existsSync)((0, import_node_path.join)(dir, ".git"))) return false;
333
+ if (dir !== session && isPackage(dir)) return false;
334
+ }
335
+ return true;
336
+ }
337
+ var uid = () => process.getuid?.() ?? (0, import_node_os.userInfo)().username;
338
+ function privateDir(dir) {
339
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true, mode: 448 });
340
+ const stat = (0, import_node_fs.lstatSync)(dir);
341
+ if (!stat.isDirectory())
342
+ throw new Error(`${dir} is not a directory; remove it and retry`);
343
+ if (process.getuid && stat.uid !== process.getuid())
344
+ throw new Error(`${dir} belongs to another user; refusing to use it`);
345
+ if (process.platform !== "win32" && (stat.mode & 63) !== 0)
346
+ (0, import_node_fs.chmodSync)(dir, 448);
347
+ return dir;
348
+ }
349
+ function stateDir() {
350
+ return privateDir(
351
+ process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? (0, import_node_path.join)(process.env.XDG_RUNTIME_DIR, "agent-bridge") : (0, import_node_path.join)((0, import_node_os.tmpdir)(), `agent-bridge-${uid()}`))
352
+ );
353
+ }
354
+ var shortHash = (text) => (0, import_node_crypto.createHash)("sha256").update(text).digest("hex").slice(0, 16);
355
+ function socketPath(name, dir) {
356
+ if (process.platform === "win32")
357
+ return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
358
+ const path = (0, import_node_path.join)(dir, `${name}.sock`);
359
+ if (Buffer.byteLength(path) <= 100) return path;
360
+ const short = privateDir(`/tmp/agent-bridge-${uid()}`);
361
+ return (0, import_node_path.join)(short, `${shortHash(path)}.sock`);
362
+ }
363
+ var sessionFiles = (name, dir = stateDir()) => ({
364
+ state: (0, import_node_path.join)(dir, `${name}.json`),
365
+ error: (0, import_node_path.join)(dir, `${name}.error`),
366
+ log: (0, import_node_path.join)(dir, `${name}.log`),
367
+ socket: socketPath(name, dir)
368
+ });
369
+ function checkName(name) {
370
+ if (!/^[\w.-]{1,40}$/.test(name))
371
+ throw new Error(
372
+ `Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
373
+ );
374
+ return name;
375
+ }
376
+ function isAlive(pid) {
377
+ try {
378
+ process.kill(pid, 0);
379
+ return true;
380
+ } catch (error) {
381
+ return error.code === "EPERM";
382
+ }
383
+ }
384
+ function removeSessionFiles(name, dir = stateDir()) {
385
+ const files = sessionFiles(name, dir);
386
+ (0, import_node_fs.rmSync)(files.state, { force: true });
387
+ if (process.platform !== "win32") (0, import_node_fs.rmSync)(files.socket, { force: true });
388
+ }
389
+ function readState(file) {
390
+ try {
391
+ return JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+ function listSessions(dir = stateDir()) {
397
+ const live = [];
398
+ for (const entry of (0, import_node_fs.readdirSync)(dir)) {
399
+ if (!entry.endsWith(".json")) continue;
400
+ const state = readState((0, import_node_path.join)(dir, entry));
401
+ if (!state) continue;
402
+ if (isAlive(state.pid)) live.push(state);
403
+ else removeSessionFiles(state.name, dir);
404
+ }
405
+ return live.sort((a, b) => a.startedAt - b.startedAt);
406
+ }
407
+ function readSession(name, dir = stateDir()) {
408
+ return listSessions(dir).find((s) => s.name === name) ?? null;
409
+ }
410
+ function pickSession(filter) {
411
+ if (filter.name) {
412
+ const named = readSession(checkName(filter.name));
413
+ if (!named) throw new Error(`No session named "${filter.name}" is running`);
414
+ return named;
415
+ }
416
+ const device = filter.device?.toLowerCase();
417
+ const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
418
+ const project = projectRoot();
419
+ const matching = listSessions().filter(
420
+ (s) => !filter.skip?.(s) && (explicit || inProject(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)
421
+ );
422
+ if (!explicit && matching.length > 1 && matching.every((s) => s.project !== project))
423
+ return null;
424
+ if (matching.length > 1)
425
+ throw new Error(
426
+ `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`
427
+ );
428
+ return matching[0] ?? null;
429
+ }
430
+
431
+ // src/client/metro-hint.ts
432
+ var range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
433
+ var probe = {
434
+ ports: [...range(8081, 8090), ...range(8100, 8199)],
435
+ eachMs: 300,
436
+ totalMs: 1e3
437
+ };
438
+ var hostOf = (metro) => {
439
+ const colon = metro.lastIndexOf(":");
440
+ return colon > 0 && !metro.endsWith("]") ? metro.slice(0, colon) : metro;
441
+ };
442
+ async function answers(metro, signal) {
443
+ try {
444
+ const res = await fetch(`http://${metro}/status`, { signal });
445
+ return (await res.text()).includes("packager-status:running");
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+ async function appsOn(metro, signal) {
451
+ try {
452
+ const res = await fetch(`http://${metro}/json/list`, { signal });
453
+ const targets = await res.json();
454
+ const names = targets.map(
455
+ (t) => `${t.title ?? "?"}${t.deviceName ? ` [${t.deviceName}]` : ""}`
456
+ );
457
+ return [...new Set(names)];
458
+ } catch {
459
+ return [];
460
+ }
461
+ }
462
+ async function findMetros(metro) {
463
+ const host = hostOf(metro);
464
+ const candidates = new Set(probe.ports.map((port) => `${host}:${port}`));
465
+ try {
466
+ for (const s of listSessions()) candidates.add(s.metro);
467
+ } catch {
468
+ }
469
+ candidates.delete(metro);
470
+ const deadline = AbortSignal.timeout(probe.totalMs);
471
+ const within2 = () => AbortSignal.any([deadline, AbortSignal.timeout(probe.eachMs)]);
472
+ const running = (await Promise.all(
473
+ [...candidates].map(async (m) => await answers(m, within2()) ? m : null)
474
+ )).filter((m) => m !== null);
475
+ return Promise.all(
476
+ running.map(async (m) => ({ metro: m, apps: await appsOn(m, within2()) }))
477
+ );
478
+ }
479
+ var label = (found) => `${found.metro} (${found.apps.length ? `${found.apps.length === 1 ? "app" : "apps"}: ${found.apps.join(", ")}` : "no app connected"})`;
480
+ var howToSet = (metro) => `Pass --metro ${metro} or set AGENT_BRIDGE_METRO=${metro}.`;
481
+ function elsewhere(found) {
482
+ const [only] = found;
483
+ if (found.length === 1 && only)
484
+ return `Metro is running on ${label(only)}. ${howToSet(only.metro)}`;
485
+ return `Metro is running on ${found.map(label).join("; ")}. ${howToSet("<host:port>")}`;
486
+ }
487
+ var errorText = (error) => {
488
+ if (!(error instanceof Error)) return String(error);
489
+ const code = error.code ?? "";
490
+ return `${error.message} ${code} ${error.cause ? errorText(error.cause) : ""}`;
491
+ };
492
+ var isRefused = (error) => /ECONNREFUSED|ConnectionRefused/.test(errorText(error));
493
+ var NO_APP = /No app is connected to Metro|No app matches/;
494
+ async function refusedMessage(metro) {
495
+ const found = await findMetros(metro);
496
+ if (found.length)
497
+ return `Nothing is listening on ${metro}. ${elsewhere(found)}`;
498
+ const host = hostOf(metro);
499
+ 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>.`;
500
+ }
501
+ async function noAppHint(metro, device) {
502
+ 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` : ""}.`;
503
+ const withApps = (await findMetros(metro)).filter((f) => f.apps.length);
504
+ return withApps.length ? `${causes} ${elsewhere(withApps)}` : causes;
505
+ }
506
+ async function refused(metro) {
507
+ try {
508
+ await fetch(`http://${metro}/status`, {
509
+ signal: AbortSignal.timeout(probe.eachMs)
510
+ });
511
+ return false;
512
+ } catch (error) {
513
+ return isRefused(error);
514
+ }
515
+ }
516
+ async function explainConnectError(error, metro, device) {
517
+ const message = error instanceof Error ? error.message : String(error);
518
+ if (NO_APP.test(message))
519
+ return new Error(`${message}
520
+ ${await noAppHint(metro, device)}`, {
521
+ cause: error
522
+ });
523
+ if (await refused(metro))
524
+ return new Error(await refusedMessage(metro), { cause: error });
525
+ return error;
526
+ }
527
+
298
528
  // src/client/cdp.ts
299
529
  var import_ws2 = __toESM(require("ws"), 1);
300
530
  var CannotEvaluate = class extends Error {
@@ -399,8 +629,8 @@ async function connectCdp(metro, device, signal, pin) {
399
629
  target: targetLabel(target)
400
630
  },
401
631
  device: { ...info, name: `${info.name} (${targetLabel(target)})` },
402
- call(tool, args, timeoutMs) {
403
- const call = { id: newCallId(), tool, args };
632
+ call(tool, args, timeoutMs, extra) {
633
+ const call = { id: newCallId(), tool, args, ...extra };
404
634
  const reply = pending.wait(call.id, tool, timeoutMs);
405
635
  evaluate(
406
636
  `${CDP_GLOBAL}.dispatch(${JSON.stringify(toAsciiJson(call))})`
@@ -483,6 +713,40 @@ function logLine(entry) {
483
713
  }
484
714
 
485
715
  // src/client/flow.ts
716
+ function deepMatches(actual, expected) {
717
+ if (expected instanceof RegExp) return typeof actual === "string" && expected.test(actual);
718
+ if (Array.isArray(expected))
719
+ return Array.isArray(actual) && actual.length === expected.length && expected.every((e, i) => deepMatches(actual[i], e));
720
+ if (expected && typeof expected === "object") {
721
+ const proto = Object.getPrototypeOf(expected);
722
+ if (proto !== Object.prototype && proto !== null)
723
+ throw new Error(
724
+ `A match can hold plain objects, arrays, values and RegExps, not a ${expected.constructor?.name ?? "class instance"}`
725
+ );
726
+ return !!actual && typeof actual === "object" && Object.entries(expected).every(
727
+ ([k, v]) => deepMatches(actual[k], v)
728
+ );
729
+ }
730
+ return Object.is(actual, expected);
731
+ }
732
+ var MAX_SCREEN_LINES = 40;
733
+ function compactScreen(snapshot) {
734
+ const elements = snapshot?.elements;
735
+ if (!Array.isArray(elements)) return [];
736
+ const lines = elements.slice(0, MAX_SCREEN_LINES).map((raw) => {
737
+ const e = raw;
738
+ const parts = [String(e.kind ?? "element")];
739
+ if (e.testID) parts.push(`#${String(e.testID)}`);
740
+ for (const key of ["text", "label", "placeholder", "value"])
741
+ if (typeof e[key] === "string" && e[key]) parts.push(JSON.stringify(e[key]));
742
+ if (e.disabled) parts.push("disabled");
743
+ if (e.checked) parts.push(`checked=${String(e.checked)}`);
744
+ return parts.join(" ");
745
+ });
746
+ if (elements.length > MAX_SCREEN_LINES)
747
+ lines.push(`... ${elements.length - MAX_SCREEN_LINES} more`);
748
+ return lines;
749
+ }
486
750
  function flowScenarios(flow) {
487
751
  const declared = [
488
752
  ...flow.scenario === void 0 ? [] : [flow.scenario],
@@ -510,6 +774,10 @@ async function runFlow(bridge, flow, options = {}) {
510
774
  let errors = 0;
511
775
  const t0 = performance.now();
512
776
  const notices = [];
777
+ let lost = false;
778
+ const noteOutcome = (error) => {
779
+ lost = error !== void 0 && !(error instanceof AgentBridgeCallError);
780
+ };
513
781
  const report = (logs, notice) => {
514
782
  errors += logs.length;
515
783
  for (const e of logs) print(` ${logLine(e)}`);
@@ -521,28 +789,82 @@ async function runFlow(bridge, flow, options = {}) {
521
789
  const timed = async (tool, ...args) => {
522
790
  try {
523
791
  const result = await bridge.timed(tool, ...args);
792
+ noteOutcome();
524
793
  report(result.logs, result.notice);
525
794
  return result;
526
795
  } catch (error) {
796
+ noteOutcome(error);
527
797
  report(failedLogs(error), failedNotice(error));
528
798
  throw error;
529
799
  }
530
800
  };
531
801
  const call = async (tool, ...args) => (await timed(tool, ...args)).value;
532
- const step = async (label, tool, ...given) => {
533
- const args = given.length === 1 && Array.isArray(given[0]) ? given[0] : given;
534
- const { value, ms, logs, notice } = await bridge.timed(tool, ...args).catch((error) => {
535
- report(failedLogs(error), failedNotice(error));
536
- throw error;
537
- });
802
+ const record = (label2, ms) => {
538
803
  total += ms;
539
804
  print(
540
- `${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
805
+ `${String(++n).padStart(2, "0")} ${label2.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
541
806
  );
542
- report(logs, notice);
543
- return value;
807
+ };
808
+ const step = async (label2, tool, ...given) => {
809
+ const args = given.length === 1 && Array.isArray(given[0]) ? given[0] : given;
810
+ const result = await timed(tool, ...args);
811
+ record(label2, result.ms);
812
+ return result.value;
813
+ };
814
+ const message = (error) => error instanceof Error ? error.message : String(error);
815
+ const expectApi = {
816
+ text: async (target, o) => {
817
+ await step(`expect text ${JSON.stringify(target)}`, "screen.waitFor", target, o ?? {}).catch(
818
+ (error) => {
819
+ throw new Error(`expected ${JSON.stringify(target)} on screen: ${message(error)}`);
820
+ }
821
+ );
822
+ },
823
+ gone: async (target, o) => {
824
+ await step(`expect gone ${JSON.stringify(target)}`, "screen.waitFor", target, {
825
+ ...o,
826
+ gone: true
827
+ }).catch((error) => {
828
+ throw new Error(`expected ${JSON.stringify(target)} to be off screen: ${message(error)}`);
829
+ });
830
+ },
831
+ route: async (pathname, o) => {
832
+ const label2 = `expect route ${pathname}`;
833
+ const t02 = performance.now();
834
+ const deadline = t02 + (o?.timeoutMs ?? 2e3);
835
+ for (; ; ) {
836
+ const route = await call("router.current").catch((error) => {
837
+ record(label2, performance.now() - t02);
838
+ throw error;
839
+ });
840
+ if (route?.pathname === pathname) return record(label2, performance.now() - t02);
841
+ if (performance.now() >= deadline) {
842
+ record(label2, performance.now() - t02);
843
+ throw new Error(`expected route ${pathname}, got ${route?.pathname ?? JSON.stringify(route)}`);
844
+ }
845
+ await new Promise((r) => setTimeout(r, 50));
846
+ }
847
+ },
848
+ result: async (tool, args, match) => {
849
+ const value = await step(`expect ${tool}`, tool, args);
850
+ if (!deepMatches(value, match))
851
+ throw new Error(
852
+ `expected ${tool} to match ${JSON.stringify(match)}, got ${JSON.stringify(value)}`
853
+ );
854
+ }
855
+ };
856
+ const failureState = async () => {
857
+ const has = (name) => bridge.tools().some((t) => t.name === name);
858
+ const state = {};
859
+ if (has("screen.snapshot"))
860
+ state.screen = await call("screen.snapshot").then(compactScreen).catch(() => void 0);
861
+ if (has("router.current") && !lost)
862
+ state.route = await call("router.current").catch(() => void 0);
863
+ return state;
544
864
  };
545
865
  const restoreErrors = [];
866
+ let flowErrors = 0;
867
+ let unreachable = false;
546
868
  const applied = {};
547
869
  if (needs.length && !bridge.tools().some((t) => t.name === "scenario.apply"))
548
870
  throw new Error(
@@ -557,10 +879,22 @@ async function runFlow(bridge, flow, options = {}) {
557
879
  bridge: { ...bridge, timed, call },
558
880
  call,
559
881
  step,
560
- scenarios: applied
882
+ scenarios: applied,
883
+ expect: expectApi
561
884
  });
885
+ } catch (error) {
886
+ const state = lost ? {} : await failureState();
887
+ if (state.route !== void 0) print(` route: ${JSON.stringify(state.route)}`);
888
+ if (state.screen?.length) {
889
+ print(" on screen:");
890
+ for (const line of state.screen) print(` ${line}`);
891
+ }
892
+ options.onFailure?.(state);
893
+ throw error;
562
894
  } finally {
563
- if (needs.length) {
895
+ flowErrors = errors;
896
+ unreachable = lost;
897
+ if (!lost && !options.keep && (needs.length || bridge.tools().some((t) => t.name === "bridge.restore"))) {
564
898
  try {
565
899
  const result = await step("restore", "bridge.restore");
566
900
  for (const [name, value] of Object.entries(result ?? {})) {
@@ -568,17 +902,70 @@ async function runFlow(bridge, flow, options = {}) {
568
902
  if (error !== void 0) restoreErrors.push(`${name}: ${String(error)}`);
569
903
  }
570
904
  } catch (error) {
905
+ unreachable = lost;
571
906
  restoreErrors.push(
572
907
  `bridge.restore: ${error instanceof Error ? error.message : String(error)}`
573
908
  );
574
909
  }
575
910
  for (const e of restoreErrors) print(` ! restore failed: ${e}`);
576
911
  }
912
+ options.onFinish?.({ steps: n, errors: flowErrors, restoreErrors, notices, unreachable });
577
913
  }
578
914
  print(
579
- `${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}${notices.length ? ", the app reloaded" : ""}`
915
+ `${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${flowErrors ? `, ${flowErrors} error${flowErrors === 1 ? "" : "s"}` : ""}${notices.length ? ", the app reloaded" : ""}`
580
916
  );
581
- return { steps: n, errors, restoreErrors, notices };
917
+ return { steps: n, errors: flowErrors, restoreErrors, notices, unreachable };
918
+ }
919
+ async function runFlows(bridge, entries, options = {}) {
920
+ const print = options.print ?? console.log;
921
+ const printError = options.single ? options.printError ?? console.error : print;
922
+ const runs = [];
923
+ let unreachable = false;
924
+ for (const entry of entries) {
925
+ const base = { file: entry.file, name: entry.name, restoreErrors: [], notices: [] };
926
+ const skip = unreachable ? "skipped: the app stopped answering in an earlier flow" : options.bail && runs.some((r) => r.status === "failed") ? "skipped: an earlier flow failed (--bail)" : void 0;
927
+ if (skip) {
928
+ runs.push({ ...base, status: "skipped", ms: 0, steps: 0, errors: 0, error: skip });
929
+ continue;
930
+ }
931
+ if (!options.single) print(`
932
+ ${entry.name}`);
933
+ const t0 = performance.now();
934
+ let state;
935
+ let done = { steps: 0, errors: 0, restoreErrors: [], notices: [], unreachable: false };
936
+ const reasons = [];
937
+ try {
938
+ await runFlow(bridge, entry.module, {
939
+ ...options,
940
+ onFailure: (s) => {
941
+ state = s;
942
+ },
943
+ onFinish: (r) => {
944
+ done = r;
945
+ }
946
+ });
947
+ } catch (error) {
948
+ const why = error instanceof Error ? error.message : String(error);
949
+ printError(` FAILED: ${why}`);
950
+ reasons.push(why);
951
+ }
952
+ if (done.restoreErrors.length) reasons.push(`restore failed: ${done.restoreErrors.join("; ")}`);
953
+ if (options.strict && done.errors)
954
+ reasons.push(`the app logged ${done.errors} error${done.errors === 1 ? "" : "s"}`);
955
+ if (done.notices.length)
956
+ print(" ! the app reloaded: changes made before the reload could not be undone");
957
+ const { unreachable: gone, ...result } = done;
958
+ runs.push({
959
+ ...base,
960
+ ...result,
961
+ status: reasons.length ? "failed" : "passed",
962
+ ms: performance.now() - t0,
963
+ ...reasons.length && { error: reasons.join("; ") },
964
+ ...state && { state }
965
+ });
966
+ if (gone) unreachable = true;
967
+ }
968
+ return runs;
582
969
  }
583
970
 
584
971
  // src/client/session/client.ts
@@ -589,123 +976,27 @@ var import_node_readline = require("readline");
589
976
  var encodeLine = (message) => `${JSON.stringify(message).replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`)}
590
977
  `;
591
978
 
592
- // src/client/session/state.ts
593
- var import_node_crypto = require("crypto");
594
- var import_node_fs = require("fs");
595
- var import_node_os = require("os");
596
- var import_node_path = require("path");
597
- function projectRoot(from = process.cwd()) {
598
- for (let dir = (0, import_node_path.resolve)(from); ; dir = (0, import_node_path.dirname)(dir)) {
599
- if ((0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "package.json"))) return dir;
600
- if ((0, import_node_path.dirname)(dir) === dir) return (0, import_node_path.resolve)(from);
601
- }
602
- }
603
- var uid = () => process.getuid?.() ?? (0, import_node_os.userInfo)().username;
604
- function privateDir(dir) {
605
- (0, import_node_fs.mkdirSync)(dir, { recursive: true, mode: 448 });
606
- const stat = (0, import_node_fs.lstatSync)(dir);
607
- if (!stat.isDirectory())
608
- throw new Error(`${dir} is not a directory; remove it and retry`);
609
- if (process.getuid && stat.uid !== process.getuid())
610
- throw new Error(`${dir} belongs to another user; refusing to use it`);
611
- if (process.platform !== "win32" && (stat.mode & 63) !== 0)
612
- (0, import_node_fs.chmodSync)(dir, 448);
613
- return dir;
614
- }
615
- function stateDir() {
616
- return privateDir(
617
- process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? (0, import_node_path.join)(process.env.XDG_RUNTIME_DIR, "agent-bridge") : (0, import_node_path.join)((0, import_node_os.tmpdir)(), `agent-bridge-${uid()}`))
618
- );
619
- }
620
- var shortHash = (text) => (0, import_node_crypto.createHash)("sha256").update(text).digest("hex").slice(0, 16);
621
- function socketPath(name, dir) {
622
- if (process.platform === "win32")
623
- return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
624
- const path = (0, import_node_path.join)(dir, `${name}.sock`);
625
- if (Buffer.byteLength(path) <= 100) return path;
626
- const short = privateDir(`/tmp/agent-bridge-${uid()}`);
627
- return (0, import_node_path.join)(short, `${shortHash(path)}.sock`);
628
- }
629
- var sessionFiles = (name, dir = stateDir()) => ({
630
- state: (0, import_node_path.join)(dir, `${name}.json`),
631
- error: (0, import_node_path.join)(dir, `${name}.error`),
632
- log: (0, import_node_path.join)(dir, `${name}.log`),
633
- socket: socketPath(name, dir)
634
- });
635
- function checkName(name) {
636
- if (!/^[\w.-]{1,40}$/.test(name))
637
- throw new Error(
638
- `Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
639
- );
640
- return name;
641
- }
642
- function isAlive(pid) {
643
- try {
644
- process.kill(pid, 0);
645
- return true;
646
- } catch (error) {
647
- return error.code === "EPERM";
648
- }
649
- }
650
- function removeSessionFiles(name, dir = stateDir()) {
651
- const files = sessionFiles(name, dir);
652
- (0, import_node_fs.rmSync)(files.state, { force: true });
653
- if (process.platform !== "win32") (0, import_node_fs.rmSync)(files.socket, { force: true });
654
- }
655
- function readState(file) {
656
- try {
657
- return JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
658
- } catch {
659
- return null;
660
- }
661
- }
662
- function listSessions(dir = stateDir()) {
663
- const live = [];
664
- for (const entry of (0, import_node_fs.readdirSync)(dir)) {
665
- if (!entry.endsWith(".json")) continue;
666
- const state = readState((0, import_node_path.join)(dir, entry));
667
- if (!state) continue;
668
- if (isAlive(state.pid)) live.push(state);
669
- else removeSessionFiles(state.name, dir);
670
- }
671
- return live.sort((a, b) => a.startedAt - b.startedAt);
672
- }
673
- function readSession(name, dir = stateDir()) {
674
- return listSessions(dir).find((s) => s.name === name) ?? null;
675
- }
676
- function pickSession(filter) {
677
- if (filter.name) {
678
- const named = readSession(checkName(filter.name));
679
- if (!named) throw new Error(`No session named "${filter.name}" is running`);
680
- return named;
681
- }
682
- const device = filter.device?.toLowerCase();
683
- const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
684
- const project = projectRoot();
685
- const matching = listSessions().filter(
686
- (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)
687
- );
688
- if (matching.length > 1)
689
- throw new Error(
690
- `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`
691
- );
692
- return matching[0] ?? null;
693
- }
694
-
695
979
  // src/client/session/client.ts
980
+ var SessionLinkError = class extends Error {
981
+ constructor(message) {
982
+ super(message);
983
+ this.name = "SessionLinkError";
984
+ }
985
+ };
696
986
  function openSessionLink(state) {
697
987
  return new Promise((resolve2, reject) => {
698
988
  const socket = (0, import_node_net.createConnection)(state.socket);
699
989
  const waiting = /* @__PURE__ */ new Map();
700
990
  let seq = 0;
701
991
  let open = false;
702
- const gone = `Session "${state.name}" closed the connection`;
992
+ let lastError = "";
993
+ const gone = () => `Session "${state.name}" closed the connection${lastError && `: ${lastError}`}`;
703
994
  socket.once("connect", () => {
704
995
  open = true;
705
996
  resolve2({
706
997
  request: (req) => new Promise((done, fail) => {
707
998
  if (socket.destroyed || socket.writableEnded) {
708
- fail(new Error(gone));
999
+ fail(new SessionLinkError(gone()));
709
1000
  return;
710
1001
  }
711
1002
  const id = ++seq;
@@ -716,18 +1007,22 @@ function openSessionLink(state) {
716
1007
  });
717
1008
  });
718
1009
  socket.on("error", (error) => {
1010
+ lastError = error.message;
719
1011
  if (!open)
720
1012
  reject(
721
- new Error(
1013
+ new SessionLinkError(
722
1014
  `Session "${state.name}" isn't answering on ${state.socket}: ${error.message}`
723
1015
  )
724
1016
  );
725
1017
  });
726
1018
  socket.on("close", () => {
727
- for (const [id, done] of waiting) done({ id, error: gone });
1019
+ for (const [id, done] of waiting) done({ id, error: gone(), linkLost: true });
728
1020
  waiting.clear();
729
1021
  });
730
- (0, import_node_readline.createInterface)({ input: socket }).on("line", (line) => {
1022
+ const lines = (0, import_node_readline.createInterface)({ input: socket });
1023
+ lines.on("error", () => {
1024
+ });
1025
+ lines.on("line", (line) => {
731
1026
  const res = JSON.parse(line);
732
1027
  waiting.get(res.id)?.(res);
733
1028
  waiting.delete(res.id);
@@ -741,10 +1036,16 @@ async function connectSession(options = {}) {
741
1036
  device: options.device,
742
1037
  transport: options.transport
743
1038
  });
744
- if (!state)
1039
+ if (!state) {
1040
+ const where = [
1041
+ explicitMetro(options.metro) && `on Metro ${explicitMetro(options.metro)}`,
1042
+ options.device && `on a device matching "${options.device}"`,
1043
+ options.transport && options.transport !== "auto" && `using ${options.transport}`
1044
+ ].filter(Boolean);
745
1045
  throw new Error(
746
- "No single session matches. Start one with `agent-bridge session start`, or name it."
1046
+ `No single session matches (looked for sessions ${where.length ? where.join(", ") : `started in ${projectRoot()}, or in an app folder under it`}). Start one with \`agent-bridge session start\`, or name it.`
747
1047
  );
1048
+ }
748
1049
  const link = await openSessionLink(state);
749
1050
  const info = await link.request({ op: "info" });
750
1051
  if (info.error || !info.device || !info.transport) {
@@ -761,6 +1062,7 @@ async function connectSession(options = {}) {
761
1062
  });
762
1063
  if (res.error || !res.result) {
763
1064
  const why = res.error ?? "No result";
1065
+ if (res.linkLost) throw new SessionLinkError(why);
764
1066
  throw res.notice ? new AgentBridgeCallError(tool, why, [], res.notice) : new Error(why);
765
1067
  }
766
1068
  const result = res.result;
@@ -813,10 +1115,12 @@ Warning: ${notice}` : ""}`);
813
1115
  };
814
1116
  async function connect(options = {}) {
815
1117
  const timeoutMs = options.timeoutMs ?? 1e4;
816
- const conn = await openConnection(options);
1118
+ const conn = await openConnection(options).catch(async (error) => {
1119
+ throw await explainConnectError(error, metroHost(options.metro), options.device);
1120
+ });
817
1121
  const timed = async (tool, ...args) => {
818
1122
  const t0 = performance.now();
819
- const result = await conn.call(tool, args, timeoutMs);
1123
+ const result = await conn.call(tool, args, callTimeout(timeoutMs, tool, args));
820
1124
  const ms = performance.now() - t0;
821
1125
  const logs = result.logs ?? [];
822
1126
  if (!result.ok) throw new AgentBridgeCallError(tool, result.error, logs);
@@ -857,5 +1161,6 @@ async function listDevices(options = {}) {
857
1161
  connectSession,
858
1162
  listDevices,
859
1163
  listSessions,
860
- runFlow
1164
+ runFlow,
1165
+ runFlows
861
1166
  });