@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
@@ -1,3 +1,18 @@
1
+ // src/client/call-timeout.ts
2
+ var WAITERS = {
3
+ "screen.waitFor": 2e3,
4
+ // The page helper adds 5 s to timeoutMs before it gives up.
5
+ "webview.waitFor": 7e3
6
+ };
7
+ var MAX_MS = 10 * 6e4;
8
+ function callTimeout(base, tool, args) {
9
+ const margin = WAITERS[tool];
10
+ const t = args[1]?.timeoutMs;
11
+ if (margin === void 0 || typeof t !== "number" || !(t > 0) || !Number.isFinite(t))
12
+ return base;
13
+ return Math.max(base, Math.min(t + margin, MAX_MS));
14
+ }
15
+
1
16
  // src/client/discover.ts
2
17
  var within = (ms, signal) => signal ? AbortSignal.any([AbortSignal.timeout(ms), signal]) : AbortSignal.timeout(ms);
3
18
  var DEFAULT_METRO = "localhost:8081";
@@ -35,11 +50,11 @@ async function listCdpTargets(metro, signal) {
35
50
  );
36
51
  return await res.json();
37
52
  }
38
- function pickOne(items, filter, label) {
53
+ function pickOne(items, filter, label2) {
39
54
  const matching = filter ? items.filter(
40
- (item) => label(item).toLowerCase().includes(filter.toLowerCase())
55
+ (item) => label2(item).toLowerCase().includes(filter.toLowerCase())
41
56
  ) : items;
42
- const connected = items.map(label).join("; ") || "none";
57
+ const connected = items.map(label2).join("; ") || "none";
43
58
  if (matching.length === 0) {
44
59
  throw new Error(
45
60
  filter ? `No app matches "${filter}". Connected: ${connected}` : "No app is connected to Metro."
@@ -52,11 +67,11 @@ function pickOne(items, filter, label) {
52
67
  }
53
68
  if (matching.length > 1) {
54
69
  const exact = matching.filter(
55
- (item) => label(item).toLowerCase() === filter?.toLowerCase()
70
+ (item) => label2(item).toLowerCase() === filter?.toLowerCase()
56
71
  );
57
72
  if (exact.length === 1) return exact[0];
58
73
  throw new Error(
59
- `${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label).join("; ")}`
74
+ `${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label2).join("; ")}`
60
75
  );
61
76
  }
62
77
  return matching[0];
@@ -232,7 +247,7 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
232
247
  transport: "expo",
233
248
  device: info,
234
249
  pin: { name: info.name, platform: info.platform },
235
- call(tool, args, timeoutMs) {
250
+ call(tool, args, timeoutMs, extra) {
236
251
  if (superseded)
237
252
  return Promise.resolve({
238
253
  id: "",
@@ -245,7 +260,8 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
245
260
  id: newCallId(),
246
261
  tool,
247
262
  args,
248
- to: info.deviceId
263
+ to: info.deviceId,
264
+ ...extra
249
265
  };
250
266
  const reply = pending.wait(call.id, tool, timeoutMs);
251
267
  send("call", call);
@@ -255,6 +271,247 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
255
271
  };
256
272
  }
257
273
 
274
+ // src/client/session/state.ts
275
+ import { createHash } from "crypto";
276
+ import {
277
+ chmodSync,
278
+ existsSync,
279
+ lstatSync,
280
+ mkdirSync,
281
+ readFileSync,
282
+ readdirSync,
283
+ rmSync,
284
+ writeFileSync
285
+ } from "fs";
286
+ import { tmpdir, userInfo } from "os";
287
+ import { dirname, join, resolve, sep } from "path";
288
+ function projectRoot(from = process.cwd()) {
289
+ for (let dir = resolve(from); ; dir = dirname(dir)) {
290
+ if (existsSync(join(dir, "package.json"))) return dir;
291
+ if (dirname(dir) === dir) return resolve(from);
292
+ }
293
+ }
294
+ var isPackage = (dir) => existsSync(join(dir, "package.json"));
295
+ function inProject(session, project) {
296
+ if (!session) return false;
297
+ if (session === project) return true;
298
+ if (!session.startsWith(project + sep) || !isPackage(project)) return false;
299
+ for (let dir = session; dir !== project; dir = dirname(dir)) {
300
+ if (existsSync(join(dir, ".git"))) return false;
301
+ if (dir !== session && isPackage(dir)) return false;
302
+ }
303
+ return true;
304
+ }
305
+ var uid = () => process.getuid?.() ?? userInfo().username;
306
+ function privateDir(dir) {
307
+ mkdirSync(dir, { recursive: true, mode: 448 });
308
+ const stat = lstatSync(dir);
309
+ if (!stat.isDirectory())
310
+ throw new Error(`${dir} is not a directory; remove it and retry`);
311
+ if (process.getuid && stat.uid !== process.getuid())
312
+ throw new Error(`${dir} belongs to another user; refusing to use it`);
313
+ if (process.platform !== "win32" && (stat.mode & 63) !== 0)
314
+ chmodSync(dir, 448);
315
+ return dir;
316
+ }
317
+ function stateDir() {
318
+ return privateDir(
319
+ process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? join(process.env.XDG_RUNTIME_DIR, "agent-bridge") : join(tmpdir(), `agent-bridge-${uid()}`))
320
+ );
321
+ }
322
+ var shortHash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
323
+ function socketPath(name, dir) {
324
+ if (process.platform === "win32")
325
+ return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
326
+ const path = join(dir, `${name}.sock`);
327
+ if (Buffer.byteLength(path) <= 100) return path;
328
+ const short = privateDir(`/tmp/agent-bridge-${uid()}`);
329
+ return join(short, `${shortHash(path)}.sock`);
330
+ }
331
+ var sessionFiles = (name, dir = stateDir()) => ({
332
+ state: join(dir, `${name}.json`),
333
+ error: join(dir, `${name}.error`),
334
+ log: join(dir, `${name}.log`),
335
+ socket: socketPath(name, dir)
336
+ });
337
+ function checkName(name) {
338
+ if (!/^[\w.-]{1,40}$/.test(name))
339
+ throw new Error(
340
+ `Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
341
+ );
342
+ return name;
343
+ }
344
+ function parseDuration(text) {
345
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(text.trim());
346
+ if (!match) throw new Error(`Can't read duration "${text}"; try 15m or 90s`);
347
+ const units = { s: 1e3, m: 6e4, h: 36e5 };
348
+ return Math.round(Number(match[1]) * (units[match[2] ?? "ms"] ?? 1));
349
+ }
350
+ function formatDuration(ms) {
351
+ const s = Math.max(0, Math.round(ms / 1e3));
352
+ if (s < 60) return `${s}s`;
353
+ if (s < 3600)
354
+ return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
355
+ return `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, "0")}m`;
356
+ }
357
+ function isAlive(pid) {
358
+ try {
359
+ process.kill(pid, 0);
360
+ return true;
361
+ } catch (error) {
362
+ return error.code === "EPERM";
363
+ }
364
+ }
365
+ function writePrivate(file, text) {
366
+ writeFileSync(file, text, { mode: 384 });
367
+ }
368
+ function removeSessionFiles(name, dir = stateDir()) {
369
+ const files = sessionFiles(name, dir);
370
+ rmSync(files.state, { force: true });
371
+ if (process.platform !== "win32") rmSync(files.socket, { force: true });
372
+ }
373
+ function readState(file) {
374
+ try {
375
+ return JSON.parse(readFileSync(file, "utf8"));
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+ function listSessions(dir = stateDir()) {
381
+ const live = [];
382
+ for (const entry of readdirSync(dir)) {
383
+ if (!entry.endsWith(".json")) continue;
384
+ const state = readState(join(dir, entry));
385
+ if (!state) continue;
386
+ if (isAlive(state.pid)) live.push(state);
387
+ else removeSessionFiles(state.name, dir);
388
+ }
389
+ return live.sort((a, b) => a.startedAt - b.startedAt);
390
+ }
391
+ function readSession(name, dir = stateDir()) {
392
+ return listSessions(dir).find((s) => s.name === name) ?? null;
393
+ }
394
+ function pickSession(filter) {
395
+ if (filter.name) {
396
+ const named = readSession(checkName(filter.name));
397
+ if (!named) throw new Error(`No session named "${filter.name}" is running`);
398
+ return named;
399
+ }
400
+ const device = filter.device?.toLowerCase();
401
+ const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
402
+ const project = projectRoot();
403
+ const matching = listSessions().filter(
404
+ (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)
405
+ );
406
+ if (!explicit && matching.length > 1 && matching.every((s) => s.project !== project))
407
+ return null;
408
+ if (matching.length > 1)
409
+ throw new Error(
410
+ `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`
411
+ );
412
+ return matching[0] ?? null;
413
+ }
414
+
415
+ // src/client/metro-hint.ts
416
+ var range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
417
+ var probe = {
418
+ ports: [...range(8081, 8090), ...range(8100, 8199)],
419
+ eachMs: 300,
420
+ totalMs: 1e3
421
+ };
422
+ var hostOf = (metro) => {
423
+ const colon = metro.lastIndexOf(":");
424
+ return colon > 0 && !metro.endsWith("]") ? metro.slice(0, colon) : metro;
425
+ };
426
+ async function answers(metro, signal) {
427
+ try {
428
+ const res = await fetch(`http://${metro}/status`, { signal });
429
+ return (await res.text()).includes("packager-status:running");
430
+ } catch {
431
+ return false;
432
+ }
433
+ }
434
+ async function appsOn(metro, signal) {
435
+ try {
436
+ const res = await fetch(`http://${metro}/json/list`, { signal });
437
+ const targets = await res.json();
438
+ const names = targets.map(
439
+ (t) => `${t.title ?? "?"}${t.deviceName ? ` [${t.deviceName}]` : ""}`
440
+ );
441
+ return [...new Set(names)];
442
+ } catch {
443
+ return [];
444
+ }
445
+ }
446
+ async function findMetros(metro) {
447
+ const host = hostOf(metro);
448
+ const candidates = new Set(probe.ports.map((port) => `${host}:${port}`));
449
+ try {
450
+ for (const s of listSessions()) candidates.add(s.metro);
451
+ } catch {
452
+ }
453
+ candidates.delete(metro);
454
+ const deadline = AbortSignal.timeout(probe.totalMs);
455
+ const within2 = () => AbortSignal.any([deadline, AbortSignal.timeout(probe.eachMs)]);
456
+ const running = (await Promise.all(
457
+ [...candidates].map(async (m) => await answers(m, within2()) ? m : null)
458
+ )).filter((m) => m !== null);
459
+ return Promise.all(
460
+ running.map(async (m) => ({ metro: m, apps: await appsOn(m, within2()) }))
461
+ );
462
+ }
463
+ var label = (found) => `${found.metro} (${found.apps.length ? `${found.apps.length === 1 ? "app" : "apps"}: ${found.apps.join(", ")}` : "no app connected"})`;
464
+ var howToSet = (metro) => `Pass --metro ${metro} or set AGENT_BRIDGE_METRO=${metro}.`;
465
+ function elsewhere(found) {
466
+ const [only] = found;
467
+ if (found.length === 1 && only)
468
+ return `Metro is running on ${label(only)}. ${howToSet(only.metro)}`;
469
+ return `Metro is running on ${found.map(label).join("; ")}. ${howToSet("<host:port>")}`;
470
+ }
471
+ var errorText = (error) => {
472
+ if (!(error instanceof Error)) return String(error);
473
+ const code = error.code ?? "";
474
+ return `${error.message} ${code} ${error.cause ? errorText(error.cause) : ""}`;
475
+ };
476
+ var isRefused = (error) => /ECONNREFUSED|ConnectionRefused/.test(errorText(error));
477
+ var NO_APP = /No app is connected to Metro|No app matches/;
478
+ async function refusedMessage(metro) {
479
+ const found = await findMetros(metro);
480
+ if (found.length)
481
+ return `Nothing is listening on ${metro}. ${elsewhere(found)}`;
482
+ const host = hostOf(metro);
483
+ 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>.`;
484
+ }
485
+ async function noAppHint(metro, device) {
486
+ 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` : ""}.`;
487
+ const withApps = (await findMetros(metro)).filter((f) => f.apps.length);
488
+ return withApps.length ? `${causes} ${elsewhere(withApps)}` : causes;
489
+ }
490
+ async function refused(metro) {
491
+ try {
492
+ await fetch(`http://${metro}/status`, {
493
+ signal: AbortSignal.timeout(probe.eachMs)
494
+ });
495
+ return false;
496
+ } catch (error) {
497
+ return isRefused(error);
498
+ }
499
+ }
500
+ async function explainConnectError(error, metro, device) {
501
+ const message = error instanceof Error ? error.message : String(error);
502
+ if (NO_APP.test(message))
503
+ return new Error(`${message}
504
+ ${await noAppHint(metro, device)}`, {
505
+ cause: error
506
+ });
507
+ if (await refused(metro))
508
+ return new Error(await refusedMessage(metro), { cause: error });
509
+ return error;
510
+ }
511
+ async function assertListening(metro) {
512
+ if (await refused(metro)) throw new Error(await refusedMessage(metro));
513
+ }
514
+
258
515
  // src/client/cdp.ts
259
516
  import WebSocket2 from "ws";
260
517
  var CannotEvaluate = class extends Error {
@@ -359,8 +616,8 @@ async function connectCdp(metro, device, signal, pin) {
359
616
  target: targetLabel(target)
360
617
  },
361
618
  device: { ...info, name: `${info.name} (${targetLabel(target)})` },
362
- call(tool, args, timeoutMs) {
363
- const call = { id: newCallId(), tool, args };
619
+ call(tool, args, timeoutMs, extra) {
620
+ const call = { id: newCallId(), tool, args, ...extra };
364
621
  const reply = pending.wait(call.id, tool, timeoutMs);
365
622
  evaluate(
366
623
  `${CDP_GLOBAL}.dispatch(${JSON.stringify(toAsciiJson(call))})`
@@ -443,6 +700,40 @@ function logLine(entry) {
443
700
  }
444
701
 
445
702
  // src/client/flow.ts
703
+ function deepMatches(actual, expected) {
704
+ if (expected instanceof RegExp) return typeof actual === "string" && expected.test(actual);
705
+ if (Array.isArray(expected))
706
+ return Array.isArray(actual) && actual.length === expected.length && expected.every((e, i) => deepMatches(actual[i], e));
707
+ if (expected && typeof expected === "object") {
708
+ const proto = Object.getPrototypeOf(expected);
709
+ if (proto !== Object.prototype && proto !== null)
710
+ throw new Error(
711
+ `A match can hold plain objects, arrays, values and RegExps, not a ${expected.constructor?.name ?? "class instance"}`
712
+ );
713
+ return !!actual && typeof actual === "object" && Object.entries(expected).every(
714
+ ([k, v]) => deepMatches(actual[k], v)
715
+ );
716
+ }
717
+ return Object.is(actual, expected);
718
+ }
719
+ var MAX_SCREEN_LINES = 40;
720
+ function compactScreen(snapshot) {
721
+ const elements = snapshot?.elements;
722
+ if (!Array.isArray(elements)) return [];
723
+ const lines = elements.slice(0, MAX_SCREEN_LINES).map((raw) => {
724
+ const e = raw;
725
+ const parts = [String(e.kind ?? "element")];
726
+ if (e.testID) parts.push(`#${String(e.testID)}`);
727
+ for (const key of ["text", "label", "placeholder", "value"])
728
+ if (typeof e[key] === "string" && e[key]) parts.push(JSON.stringify(e[key]));
729
+ if (e.disabled) parts.push("disabled");
730
+ if (e.checked) parts.push(`checked=${String(e.checked)}`);
731
+ return parts.join(" ");
732
+ });
733
+ if (elements.length > MAX_SCREEN_LINES)
734
+ lines.push(`... ${elements.length - MAX_SCREEN_LINES} more`);
735
+ return lines;
736
+ }
446
737
  function flowScenarios(flow) {
447
738
  const declared = [
448
739
  ...flow.scenario === void 0 ? [] : [flow.scenario],
@@ -483,6 +774,10 @@ async function runFlow(bridge, flow, options = {}) {
483
774
  let errors = 0;
484
775
  const t0 = performance.now();
485
776
  const notices = [];
777
+ let lost = false;
778
+ const noteOutcome = (error) => {
779
+ lost = error !== void 0 && !(error instanceof AgentBridgeCallError);
780
+ };
486
781
  const report = (logs, notice) => {
487
782
  errors += logs.length;
488
783
  for (const e of logs) print(` ${logLine(e)}`);
@@ -494,28 +789,82 @@ async function runFlow(bridge, flow, options = {}) {
494
789
  const timed = async (tool, ...args) => {
495
790
  try {
496
791
  const result = await bridge.timed(tool, ...args);
792
+ noteOutcome();
497
793
  report(result.logs, result.notice);
498
794
  return result;
499
795
  } catch (error) {
796
+ noteOutcome(error);
500
797
  report(failedLogs(error), failedNotice(error));
501
798
  throw error;
502
799
  }
503
800
  };
504
801
  const call = async (tool, ...args) => (await timed(tool, ...args)).value;
505
- const step = async (label, tool, ...given) => {
506
- const args = given.length === 1 && Array.isArray(given[0]) ? given[0] : given;
507
- const { value, ms, logs, notice } = await bridge.timed(tool, ...args).catch((error) => {
508
- report(failedLogs(error), failedNotice(error));
509
- throw error;
510
- });
802
+ const record = (label2, ms) => {
511
803
  total += ms;
512
804
  print(
513
- `${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`
514
806
  );
515
- report(logs, notice);
516
- 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;
517
864
  };
518
865
  const restoreErrors = [];
866
+ let flowErrors = 0;
867
+ let unreachable = false;
519
868
  const applied = {};
520
869
  if (needs.length && !bridge.tools().some((t) => t.name === "scenario.apply"))
521
870
  throw new Error(
@@ -530,10 +879,22 @@ async function runFlow(bridge, flow, options = {}) {
530
879
  bridge: { ...bridge, timed, call },
531
880
  call,
532
881
  step,
533
- scenarios: applied
882
+ scenarios: applied,
883
+ expect: expectApi
534
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;
535
894
  } finally {
536
- if (needs.length) {
895
+ flowErrors = errors;
896
+ unreachable = lost;
897
+ if (!lost && !options.keep && (needs.length || bridge.tools().some((t) => t.name === "bridge.restore"))) {
537
898
  try {
538
899
  const result = await step("restore", "bridge.restore");
539
900
  for (const [name, value] of Object.entries(result ?? {})) {
@@ -541,17 +902,70 @@ async function runFlow(bridge, flow, options = {}) {
541
902
  if (error !== void 0) restoreErrors.push(`${name}: ${String(error)}`);
542
903
  }
543
904
  } catch (error) {
905
+ unreachable = lost;
544
906
  restoreErrors.push(
545
907
  `bridge.restore: ${error instanceof Error ? error.message : String(error)}`
546
908
  );
547
909
  }
548
910
  for (const e of restoreErrors) print(` ! restore failed: ${e}`);
549
911
  }
912
+ options.onFinish?.({ steps: n, errors: flowErrors, restoreErrors, notices, unreachable });
550
913
  }
551
914
  print(
552
- `${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" : ""}`
553
916
  );
554
- 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;
555
969
  }
556
970
 
557
971
  // src/client/session/client.ts
@@ -562,148 +976,27 @@ import { createInterface } from "readline";
562
976
  var encodeLine = (message) => `${JSON.stringify(message).replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`)}
563
977
  `;
564
978
 
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
979
  // src/client/session/client.ts
980
+ var SessionLinkError = class extends Error {
981
+ constructor(message) {
982
+ super(message);
983
+ this.name = "SessionLinkError";
984
+ }
985
+ };
694
986
  function openSessionLink(state) {
695
987
  return new Promise((resolve2, reject) => {
696
988
  const socket = createConnection(state.socket);
697
989
  const waiting = /* @__PURE__ */ new Map();
698
990
  let seq = 0;
699
991
  let open = false;
700
- const gone = `Session "${state.name}" closed the connection`;
992
+ let lastError = "";
993
+ const gone = () => `Session "${state.name}" closed the connection${lastError && `: ${lastError}`}`;
701
994
  socket.once("connect", () => {
702
995
  open = true;
703
996
  resolve2({
704
997
  request: (req) => new Promise((done, fail) => {
705
998
  if (socket.destroyed || socket.writableEnded) {
706
- fail(new Error(gone));
999
+ fail(new SessionLinkError(gone()));
707
1000
  return;
708
1001
  }
709
1002
  const id = ++seq;
@@ -714,18 +1007,22 @@ function openSessionLink(state) {
714
1007
  });
715
1008
  });
716
1009
  socket.on("error", (error) => {
1010
+ lastError = error.message;
717
1011
  if (!open)
718
1012
  reject(
719
- new Error(
1013
+ new SessionLinkError(
720
1014
  `Session "${state.name}" isn't answering on ${state.socket}: ${error.message}`
721
1015
  )
722
1016
  );
723
1017
  });
724
1018
  socket.on("close", () => {
725
- for (const [id, done] of waiting) done({ id, error: gone });
1019
+ for (const [id, done] of waiting) done({ id, error: gone(), linkLost: true });
726
1020
  waiting.clear();
727
1021
  });
728
- createInterface({ input: socket }).on("line", (line) => {
1022
+ const lines = createInterface({ input: socket });
1023
+ lines.on("error", () => {
1024
+ });
1025
+ lines.on("line", (line) => {
729
1026
  const res = JSON.parse(line);
730
1027
  waiting.get(res.id)?.(res);
731
1028
  waiting.delete(res.id);
@@ -747,10 +1044,16 @@ async function connectSession(options = {}) {
747
1044
  device: options.device,
748
1045
  transport: options.transport
749
1046
  });
750
- if (!state)
1047
+ if (!state) {
1048
+ const where = [
1049
+ explicitMetro(options.metro) && `on Metro ${explicitMetro(options.metro)}`,
1050
+ options.device && `on a device matching "${options.device}"`,
1051
+ options.transport && options.transport !== "auto" && `using ${options.transport}`
1052
+ ].filter(Boolean);
751
1053
  throw new Error(
752
- "No single session matches. Start one with `agent-bridge session start`, or name it."
1054
+ `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.`
753
1055
  );
1056
+ }
754
1057
  const link = await openSessionLink(state);
755
1058
  const info = await link.request({ op: "info" });
756
1059
  if (info.error || !info.device || !info.transport) {
@@ -767,6 +1070,7 @@ async function connectSession(options = {}) {
767
1070
  });
768
1071
  if (res.error || !res.result) {
769
1072
  const why = res.error ?? "No result";
1073
+ if (res.linkLost) throw new SessionLinkError(why);
770
1074
  throw res.notice ? new AgentBridgeCallError(tool, why, [], res.notice) : new Error(why);
771
1075
  }
772
1076
  const result = res.result;
@@ -819,10 +1123,12 @@ Warning: ${notice}` : ""}`);
819
1123
  };
820
1124
  async function connect(options = {}) {
821
1125
  const timeoutMs = options.timeoutMs ?? 1e4;
822
- const conn = await openConnection(options);
1126
+ const conn = await openConnection(options).catch(async (error) => {
1127
+ throw await explainConnectError(error, metroHost(options.metro), options.device);
1128
+ });
823
1129
  const timed = async (tool, ...args) => {
824
1130
  const t0 = performance.now();
825
- const result = await conn.call(tool, args, timeoutMs);
1131
+ const result = await conn.call(tool, args, callTimeout(timeoutMs, tool, args));
826
1132
  const ms = performance.now() - t0;
827
1133
  const logs = result.logs ?? [];
828
1134
  if (!result.ok) throw new AgentBridgeCallError(tool, result.error, logs);
@@ -858,15 +1164,10 @@ async function listDevices(options = {}) {
858
1164
  }
859
1165
 
860
1166
  export {
1167
+ callTimeout,
861
1168
  explicitMetro,
862
1169
  metroHost,
863
1170
  RUNTIME_MARKER,
864
- openConnection,
865
- logLine,
866
- flowScenarios,
867
- parseScenarioFlag,
868
- runFlow,
869
- encodeLine,
870
1171
  projectRoot,
871
1172
  stateDir,
872
1173
  sessionFiles,
@@ -879,6 +1180,17 @@ export {
879
1180
  listSessions,
880
1181
  readSession,
881
1182
  pickSession,
1183
+ noAppHint,
1184
+ explainConnectError,
1185
+ assertListening,
1186
+ openConnection,
1187
+ logLine,
1188
+ flowScenarios,
1189
+ parseScenarioFlag,
1190
+ runFlow,
1191
+ runFlows,
1192
+ encodeLine,
1193
+ SessionLinkError,
882
1194
  sessionRequest,
883
1195
  connectSession,
884
1196
  AgentBridgeCallError,