@avasapp/agent-bridge 0.2.0 → 0.4.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 (35) hide show
  1. package/README.md +220 -20
  2. package/dist/adapters/ably.cjs +22 -4
  3. package/dist/adapters/ably.d.cts +8 -1
  4. package/dist/adapters/expo-router.cjs +22 -1
  5. package/dist/adapters/expo-router.d.cts +5 -1
  6. package/dist/adapters/react-native-mmkv.cjs +1 -0
  7. package/dist/adapters/react-native-mmkv.d.cts +1 -1
  8. package/dist/adapters/react-native-webview.cjs +672 -0
  9. package/dist/adapters/react-native-webview.d.cts +79 -0
  10. package/dist/adapters/socket-io.cjs +3 -1
  11. package/dist/adapters/socket-io.d.cts +1 -1
  12. package/dist/adapters/tanstack-query.cjs +256 -17
  13. package/dist/adapters/tanstack-query.d.cts +1 -1
  14. package/dist/adapters/zustand.cjs +152 -12
  15. package/dist/adapters/zustand.d.cts +19 -3
  16. package/dist/{chunk-LBYZ5JHF.js → chunk-AJD3XTNT.js} +329 -46
  17. package/dist/cli.js +848 -98
  18. package/dist/client/index.cjs +311 -47
  19. package/dist/client/index.d.ts +64 -3
  20. package/dist/client/index.js +5 -3
  21. package/dist/expo/index.d.cts +1 -1
  22. package/dist/network/index.cjs +434 -66
  23. package/dist/network/index.d.cts +53 -3
  24. package/dist/noop/index.cjs +12 -0
  25. package/dist/noop/network.cjs +7 -1
  26. package/dist/noop/react-native-webview.cjs +31 -0
  27. package/dist/realtime/index.cjs +2 -1
  28. package/dist/realtime/index.d.cts +3 -1
  29. package/dist/runtime/index.cjs +1067 -111
  30. package/dist/runtime/index.d.cts +51 -5
  31. package/dist/types-BmpykP1P.d.cts +137 -0
  32. package/entries/react-native-webview.cjs +9 -0
  33. package/package.json +18 -6
  34. package/skills/agent-bridge/SKILL.md +226 -8
  35. package/dist/types-C6DUUHnB.d.cts +0 -76
package/dist/cli.js CHANGED
@@ -5,21 +5,28 @@ import {
5
5
  checkName,
6
6
  connect,
7
7
  connectSession,
8
+ encodeLine,
9
+ explicitMetro,
10
+ flowScenarios,
8
11
  formatDuration,
9
12
  isAlive,
10
13
  listDevices,
11
14
  listSessions,
15
+ logLine,
12
16
  metroHost,
13
17
  openConnection,
14
18
  parseDuration,
19
+ parseScenarioFlag,
15
20
  pickSession,
21
+ projectRoot,
16
22
  readSession,
17
23
  removeSessionFiles,
24
+ runFlow,
18
25
  sessionFiles,
19
26
  sessionRequest,
20
27
  stateDir,
21
28
  writePrivate
22
- } from "./chunk-LBYZ5JHF.js";
29
+ } from "./chunk-AJD3XTNT.js";
23
30
 
24
31
  // src/cli.ts
25
32
  import { readFile } from "fs/promises";
@@ -27,22 +34,572 @@ import { resolve } from "path";
27
34
  import { pathToFileURL } from "url";
28
35
  import { parseArgs } from "util";
29
36
 
30
- // src/client/log-lines.ts
31
- function logLine(entry) {
32
- const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
33
- const first = entry.message.split("\n")[0] ?? "";
34
- const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
35
- return `! ${entry.level}${where}: ${text}`;
37
+ // src/client/batch.ts
38
+ import { readFileSync } from "fs";
39
+ import { mkdir, readdir, stat } from "fs/promises";
40
+ import { join } from "path";
41
+ import { createInterface } from "readline";
42
+
43
+ // src/client/output.ts
44
+ import { writeFile } from "fs/promises";
45
+ var OUTPUT_LIMIT_BYTES = 32 * 1024;
46
+ var kb = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
47
+ function shapeOf(value) {
48
+ const kind = (v) => Array.isArray(v) ? `array(${v.length})` : v === null ? "null" : typeof v === "object" ? `object(${Object.keys(v).length} keys)` : typeof v === "string" ? `string(${v.length})` : typeof v;
49
+ if (Array.isArray(value)) {
50
+ const first = value.length ? kind(value[0]) : void 0;
51
+ return { type: kind(value), ...first && { first } };
52
+ }
53
+ if (value && typeof value === "object")
54
+ return Object.fromEntries(
55
+ Object.entries(value).map(([k, v]) => [k, kind(v)])
56
+ );
57
+ return { type: kind(value) };
58
+ }
59
+ var fileSummary = (file, bytes, value) => ({
60
+ file,
61
+ bytes,
62
+ size: kb(bytes),
63
+ shape: shapeOf(value)
64
+ });
65
+ var tooLarge = (bytes, value, outKind = "file") => ({
66
+ resultTooLarge: true,
67
+ bytes,
68
+ size: kb(bytes),
69
+ shape: shapeOf(value),
70
+ hint: `Not printed: the result is ${kb(bytes)}. Re-run with --out <${outKind === "dir" ? "dir" : "file"}> to write it to a ${outKind === "dir" ? "file in that directory" : "file"}, or --full to print it, or narrow the call (a path, a filter, a smaller page).`
71
+ });
72
+ async function shapedResult(value, options = {}) {
73
+ const json = JSON.stringify(value, null, 2) ?? "null";
74
+ const bytes = Buffer.byteLength(json);
75
+ if (options.out) {
76
+ await writeFile(options.out, `${json}
77
+ `);
78
+ return fileSummary(options.out, bytes, value);
79
+ }
80
+ if (options.full || bytes <= (options.limit ?? OUTPUT_LIMIT_BYTES))
81
+ return value === void 0 ? null : value;
82
+ return tooLarge(bytes, value, options.outKind);
83
+ }
84
+ async function renderResult(value, options = {}) {
85
+ const json = JSON.stringify(value, null, 2) ?? "null";
86
+ const bytes = Buffer.byteLength(json);
87
+ if (options.out) {
88
+ await writeFile(options.out, `${json}
89
+ `);
90
+ return JSON.stringify(fileSummary(options.out, bytes, value), null, 2);
91
+ }
92
+ if (options.full || bytes <= (options.limit ?? OUTPUT_LIMIT_BYTES))
93
+ return json;
94
+ return JSON.stringify(tooLarge(bytes, value), null, 2);
95
+ }
96
+
97
+ // src/client/batch.ts
98
+ function parseCallArgs(raw) {
99
+ if (raw === void 0) return [];
100
+ let value;
101
+ try {
102
+ value = JSON.parse(raw);
103
+ } catch {
104
+ return [raw];
105
+ }
106
+ return Array.isArray(value) ? value : [value];
107
+ }
108
+ var readArgFile = (path) => {
109
+ try {
110
+ return readFileSync(path, "utf8");
111
+ } catch (error) {
112
+ throw new Error(
113
+ `Cannot read arguments file ${path}: ${error.code ?? error.message}`
114
+ );
115
+ }
116
+ };
117
+ function fileJson(ref, read) {
118
+ const path = ref.slice(1);
119
+ if (!path)
120
+ throw new Error(
121
+ `An argument that is just "@" needs a path (@file, or @- for stdin). To pass the text itself, quote it as JSON: '"@"'`
122
+ );
123
+ const text = read(path);
124
+ try {
125
+ return JSON.parse(text);
126
+ } catch (error) {
127
+ throw new Error(
128
+ `${path === "-" ? "stdin" : path} is not valid JSON (${error instanceof Error ? error.message : error})`
129
+ );
130
+ }
131
+ }
132
+ function parseCallArgv(argv, read = readArgFile) {
133
+ if (argv.length === 1) {
134
+ const [only] = argv;
135
+ if (!only.startsWith("@")) return parseCallArgs(only);
136
+ const value = fileJson(only, read);
137
+ return Array.isArray(value) ? value : [value];
138
+ }
139
+ return argv.map((word) => {
140
+ if (word.startsWith("@")) return fileJson(word, read);
141
+ try {
142
+ return JSON.parse(word);
143
+ } catch {
144
+ return word;
145
+ }
146
+ });
147
+ }
148
+ var BatchParseError = class extends Error {
149
+ constructor(tool, message2) {
150
+ super(message2);
151
+ this.tool = tool;
152
+ }
153
+ tool;
154
+ };
155
+ function parseBatchLine(line) {
156
+ const text = line.trim();
157
+ if (!text || text.startsWith("#")) return null;
158
+ const at = text.search(/\s/);
159
+ if (at < 0) return { tool: text, args: [] };
160
+ const tool = text.slice(0, at);
161
+ const raw = text.slice(at).trim();
162
+ if (/^[[{"]/.test(raw)) {
163
+ try {
164
+ JSON.parse(raw);
165
+ } catch (error) {
166
+ throw new BatchParseError(
167
+ tool,
168
+ `Arguments start like JSON but don't parse (${error instanceof Error ? error.message : error}). Fix the JSON, or quote the text to send it as a string: ${JSON.stringify(raw)}`
169
+ );
170
+ }
171
+ }
172
+ if (raw.startsWith("@")) {
173
+ try {
174
+ return {
175
+ tool,
176
+ args: parseCallArgv([raw], (path) => {
177
+ if (path === "-")
178
+ throw new Error("@- is not available in a batch: stdin carries the calls");
179
+ return readArgFile(path);
180
+ })
181
+ };
182
+ } catch (error) {
183
+ throw new BatchParseError(tool, error.message);
184
+ }
185
+ }
186
+ return { tool, args: parseCallArgs(raw) };
187
+ }
188
+ async function runCall(bridge, { tool, args }) {
189
+ const t0 = performance.now();
190
+ try {
191
+ const { value, ms, appMs, logs, notice } = await bridge.timed(tool, ...args);
192
+ return { tool, ok: true, ms, appMs, value, logs, notice };
193
+ } catch (error) {
194
+ const ms = performance.now() - t0;
195
+ const failed = error instanceof AgentBridgeCallError ? error : void 0;
196
+ return {
197
+ tool,
198
+ ok: false,
199
+ ms,
200
+ error: error instanceof Error ? error.message : String(error),
201
+ logs: failed?.logs ?? [],
202
+ notice: failed?.notice
203
+ };
204
+ }
205
+ }
206
+ async function runLine(bridge, line) {
207
+ try {
208
+ const parsed = parseBatchLine(line);
209
+ return parsed ? await runCall(bridge, parsed) : null;
210
+ } catch (error) {
211
+ if (!(error instanceof BatchParseError)) throw error;
212
+ return { tool: error.tool, ok: false, ms: 0, error: error.message, logs: [] };
213
+ }
214
+ }
215
+ var MAX_TIME_RUNS = 1e3;
216
+ var DEFAULT_TIME_RUNS = 10;
217
+ function parseTime(rest) {
218
+ const m = /^(.*?)(?:\s+x(\d+))?$/.exec(rest.trim());
219
+ const call = parseBatchLine(m?.[1] ?? "");
220
+ if (!call) return null;
221
+ const wanted = Math.max(1, Number(m?.[2] ?? DEFAULT_TIME_RUNS));
222
+ return { call, runs: Math.min(wanted, MAX_TIME_RUNS), capped: wanted > MAX_TIME_RUNS };
223
+ }
224
+ function spread(values) {
225
+ const sorted = [...values].sort((a, b) => a - b);
226
+ const mid = sorted.length >> 1;
227
+ const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
228
+ return { min: sorted[0], median, max: sorted.at(-1) };
229
+ }
230
+ var round = (ms) => Math.round(ms * 10) / 10;
231
+ async function dotLine(bridge, line) {
232
+ const [name = ""] = line.split(/\s+/);
233
+ const rest = line.slice(name.length).trim();
234
+ switch (name) {
235
+ case ".exit":
236
+ return null;
237
+ case ".tools":
238
+ return {
239
+ tool: name,
240
+ ok: true,
241
+ ms: 0,
242
+ value: bridge.tools().filter((t) => t.name.startsWith(rest)).map((t) => ({ name: t.name, description: t.description }))
243
+ };
244
+ case ".pending":
245
+ return runCall(bridge, { tool: "bridge.pending", args: [] });
246
+ case ".restore":
247
+ return runCall(bridge, { tool: "bridge.restore", args: [] });
248
+ case ".time": {
249
+ let request;
250
+ try {
251
+ request = parseTime(rest);
252
+ } catch (error) {
253
+ return { tool: name, ok: false, ms: 0, error: error.message };
254
+ }
255
+ if (!request)
256
+ return { tool: name, ok: false, ms: 0, error: "Usage: .time <tool> [args] [xN]" };
257
+ const trips = [];
258
+ const inApp = [];
259
+ for (let i = 0; i < request.runs; i++) {
260
+ const outcome = await runCall(bridge, request.call);
261
+ if (!outcome.ok) return outcome;
262
+ trips.push(outcome.ms);
263
+ inApp.push(outcome.appMs ?? 0);
264
+ }
265
+ const r = (s) => ({
266
+ min: round(s.min),
267
+ median: round(s.median),
268
+ max: round(s.max)
269
+ });
270
+ return {
271
+ tool: name,
272
+ ok: true,
273
+ ms: 0,
274
+ value: {
275
+ call: request.call.tool,
276
+ runs: request.runs,
277
+ roundTrip: r(spread(trips)),
278
+ inApp: r(spread(inApp))
279
+ }
280
+ };
281
+ }
282
+ default:
283
+ return {
284
+ tool: name,
285
+ ok: false,
286
+ ms: 0,
287
+ error: `Unknown command ${name}. In batch mode: .tools, .time, .pending, .restore, .exit`
288
+ };
289
+ }
290
+ }
291
+ async function checkOutDir(dir) {
292
+ const info = await stat(dir).catch(() => null);
293
+ if (!info) return mkdir(dir, { recursive: true });
294
+ if (!info.isDirectory())
295
+ throw new Error(`--out ${dir} is a file; with --batch it must be a directory`);
296
+ if ((await readdir(dir)).length)
297
+ throw new Error(
298
+ `--out ${dir} is not empty; use a new or empty directory so old files aren't mistaken for this run's`
299
+ );
300
+ }
301
+ async function runBatch(bridge, input, options = {}) {
302
+ const write = options.write ?? ((line) => console.log(line));
303
+ if (options.outDir) await checkOutDir(options.outDir);
304
+ const result = { calls: 0, failed: 0 };
305
+ const rl = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
306
+ let stopped = false;
307
+ try {
308
+ for await (const line of rl) {
309
+ const text = line.trim();
310
+ const outcome = text.startsWith(".") ? await dotLine(bridge, text) : await runLine(bridge, line);
311
+ if (text.startsWith(".") && outcome === null) break;
312
+ if (!outcome) continue;
313
+ result.calls++;
314
+ const raw = outcome;
315
+ const record = {
316
+ tool: raw.tool,
317
+ ok: raw.ok,
318
+ ms: round(raw.ms)
319
+ };
320
+ if (raw.appMs !== void 0) record.appMs = raw.appMs;
321
+ if (raw.ok) {
322
+ try {
323
+ record.value = await shapedResult(raw.value, {
324
+ full: options.full,
325
+ outKind: "dir",
326
+ out: options.outDir ? join(
327
+ options.outDir,
328
+ `${result.calls}-${String(raw.tool).replace(/[^\w.-]/g, "_")}.json`
329
+ ) : void 0
330
+ });
331
+ } catch (error) {
332
+ record.ok = false;
333
+ record.error = `Could not write the result: ${error instanceof Error ? error.message : error}`;
334
+ }
335
+ } else record.error = raw.error;
336
+ const logs = raw.logs;
337
+ if (logs?.length) record.logs = logs;
338
+ if (raw.notice) record.notice = raw.notice;
339
+ write(JSON.stringify(record));
340
+ if (!record.ok) {
341
+ result.failed++;
342
+ if (options.stopOnError) {
343
+ stopped = true;
344
+ break;
345
+ }
346
+ }
347
+ }
348
+ } finally {
349
+ rl.close();
350
+ if (stopped) input.destroy?.();
351
+ }
352
+ return result;
353
+ }
354
+
355
+ // src/client/repl.ts
356
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
357
+ import { homedir } from "os";
358
+ import { dirname, join as join2 } from "path";
359
+ import { createInterface as createInterface2 } from "readline";
360
+ var HISTORY_LIMIT = 500;
361
+ var DEFAULT_HISTORY_FILE = join2(
362
+ homedir(),
363
+ ".agent-bridge",
364
+ "repl_history"
365
+ );
366
+ var DOT_COMMANDS = [
367
+ ".help",
368
+ ".tools",
369
+ ".time",
370
+ ".pending",
371
+ ".restore",
372
+ ".exit"
373
+ ];
374
+ var HELP = `Type a call the way \`agent-bridge call\` takes it: tool [args]
375
+ screen.press "Save" one JSON value is one argument
376
+ store.set ["user", {"a":1}] a JSON array is the argument list
377
+ Dot commands
378
+ .tools [prefix] List tools, optionally starting with prefix
379
+ .time <call> [xN] Run a call N times (default 10, at most ${MAX_TIME_RUNS}); min/median/max
380
+ .pending bridge.pending: what bridge.restore would undo
381
+ .restore bridge.restore
382
+ .help This text
383
+ .exit Leave (also Ctrl-D)
384
+ Ctrl-C clears the line, or gives up waiting for a running call (the app may still finish it).
385
+ Tab completes tool names and dot commands.`;
386
+ function complete(line, toolNames) {
387
+ const first = /^\s*(\S*)$/.exec(line);
388
+ if (first) {
389
+ const word = first[1];
390
+ const pool = word.startsWith(".") ? DOT_COMMANDS : toolNames;
391
+ return [pool.filter((n) => n.startsWith(word)), word];
392
+ }
393
+ const arg = /^\s*\.(?:tools|time)\s+(\S*)$/.exec(line);
394
+ if (arg) {
395
+ const word = arg[1];
396
+ return [toolNames.filter((n) => n.startsWith(word)), word];
397
+ }
398
+ return [[], line];
399
+ }
400
+ function loadHistory(file) {
401
+ let lines;
402
+ try {
403
+ lines = readFileSync2(file, "utf8").split("\n").filter(Boolean);
404
+ } catch {
405
+ return [];
406
+ }
407
+ if (lines.length <= HISTORY_LIMIT) return lines;
408
+ saveHistory(file, lines);
409
+ return lines.slice(-HISTORY_LIMIT);
410
+ }
411
+ function saveHistory(file, lines) {
412
+ try {
413
+ mkdirSync(dirname(file), { recursive: true });
414
+ writeFileSync(file, `${lines.slice(-HISTORY_LIMIT).join("\n")}
415
+ `, {
416
+ mode: 384
417
+ });
418
+ } catch {
419
+ }
420
+ }
421
+ function appendHistory(file, line) {
422
+ try {
423
+ mkdirSync(dirname(file), { recursive: true });
424
+ appendFileSync(file, `${line}
425
+ `, { mode: 384 });
426
+ } catch {
427
+ }
428
+ }
429
+ var fixed = (s) => `min ${s.min.toFixed(1)} / median ${s.median.toFixed(1)} / max ${s.max.toFixed(1)} ms`;
430
+ var INTERRUPTED = "Interrupted. The call may still finish in the app.";
431
+ async function runRepl(bridge, options) {
432
+ const { output } = options;
433
+ const tty = !!options.tty;
434
+ const { historyFile } = options;
435
+ const paint = (code, text) => tty && !process.env.NO_COLOR ? `\x1B[${code}m${text}\x1B[0m` : text;
436
+ const print = (text = "") => {
437
+ output.write(`${text}
438
+ `);
439
+ };
440
+ const toolNames = () => bridge.tools().map((t) => t.name);
441
+ const history = historyFile ? loadHistory(historyFile) : [];
442
+ const rl = createInterface2({
443
+ input: options.input,
444
+ output,
445
+ terminal: tty,
446
+ prompt: tty ? "agent-bridge> " : "",
447
+ history: [...history].reverse(),
448
+ historySize: HISTORY_LIMIT,
449
+ completer: (line) => complete(line, toolNames())
450
+ });
451
+ let closed = false;
452
+ rl.once("close", () => {
453
+ closed = true;
454
+ });
455
+ const prompt = () => {
456
+ if (!closed) rl.prompt();
457
+ };
458
+ let giveUp = null;
459
+ rl.on("SIGINT", () => {
460
+ if (giveUp) return giveUp();
461
+ print("^C");
462
+ if (tty) rl.write(null, { ctrl: true, name: "u" });
463
+ prompt();
464
+ });
465
+ const interruptible = async (work) => {
466
+ let live = true;
467
+ let release;
468
+ const aborted = new Promise((r) => {
469
+ release = r;
470
+ });
471
+ giveUp = () => {
472
+ live = false;
473
+ release();
474
+ };
475
+ try {
476
+ await Promise.race([work(() => live), aborted]);
477
+ } finally {
478
+ giveUp = null;
479
+ }
480
+ if (!live) print(paint(33, INTERRUPTED));
481
+ };
482
+ const show = async (outcome) => {
483
+ for (const e of outcome.logs) print(paint(2, logLine(e)));
484
+ if (outcome.notice) print(paint(33, `Warning: ${outcome.notice}`));
485
+ if (!outcome.ok) {
486
+ print(paint(31, outcome.error ?? "failed"));
487
+ return;
488
+ }
489
+ print(await renderResult(outcome.value, { full: options.full }));
490
+ print(
491
+ paint(
492
+ 2,
493
+ `${outcome.ms.toFixed(1)} ms round trip, ${outcome.appMs} ms in the app`
494
+ )
495
+ );
496
+ };
497
+ const time = async (rest, live) => {
498
+ let request;
499
+ try {
500
+ request = parseTime(rest);
501
+ } catch (error) {
502
+ return print(paint(31, error instanceof Error ? error.message : String(error)));
503
+ }
504
+ if (!request) return print("Usage: .time <tool> [args] [xN]");
505
+ const { call, runs } = request;
506
+ if (request.capped)
507
+ print(paint(33, `Running ${runs} times: .time is capped at ${MAX_TIME_RUNS}.`));
508
+ const trips = [];
509
+ const inApp = [];
510
+ for (let i = 0; i < runs; i++) {
511
+ if (!live()) return;
512
+ const outcome = await runCall(bridge, call);
513
+ if (!live()) return;
514
+ if (!outcome.ok) {
515
+ await show(outcome);
516
+ return;
517
+ }
518
+ trips.push(outcome.ms);
519
+ inApp.push(outcome.appMs ?? 0);
520
+ }
521
+ print(`${call.tool} x${runs}`);
522
+ print(` round trip ${fixed(spread(trips))}`);
523
+ print(` in the app ${fixed(spread(inApp))}`);
524
+ };
525
+ const tool = (name) => (live) => runCall(bridge, { tool: name, args: [] }).then(
526
+ (outcome) => live() ? show(outcome) : void 0
527
+ );
528
+ const dot = async (line) => {
529
+ const [name = "", ...more] = line.split(/\s+/);
530
+ const rest = line.slice(name.length).trim();
531
+ switch (name) {
532
+ case ".exit":
533
+ return false;
534
+ case ".help":
535
+ print(HELP);
536
+ break;
537
+ case ".tools": {
538
+ const list2 = bridge.tools().filter((t) => t.name.startsWith(more[0] ?? ""));
539
+ if (!list2.length) print("No tools match.");
540
+ for (const t of list2)
541
+ print(` ${t.name.padEnd(22)} ${t.description ?? ""}`);
542
+ break;
543
+ }
544
+ case ".time":
545
+ await interruptible((live) => time(rest, live));
546
+ break;
547
+ case ".pending":
548
+ await interruptible(tool("bridge.pending"));
549
+ break;
550
+ case ".restore":
551
+ await interruptible(tool("bridge.restore"));
552
+ break;
553
+ default:
554
+ print(paint(31, `Unknown command ${name}. Try .help`));
555
+ }
556
+ return true;
557
+ };
558
+ options.onReady?.(rl);
559
+ if (tty) print("agent-bridge repl. .help for commands, .exit or Ctrl-D to leave.");
560
+ prompt();
561
+ let eof = false;
562
+ try {
563
+ for await (const raw of rl) {
564
+ const line = raw.trim();
565
+ if (line && !line.startsWith("#")) {
566
+ if (historyFile) appendHistory(historyFile, line);
567
+ if (line.startsWith(".")) {
568
+ if (!await dot(line)) break;
569
+ } else {
570
+ await interruptible(async (live) => {
571
+ const outcome = await runLine(bridge, line);
572
+ if (outcome && live()) await show(outcome);
573
+ });
574
+ }
575
+ }
576
+ prompt();
577
+ }
578
+ eof = closed;
579
+ } finally {
580
+ rl.close();
581
+ if (tty && eof) output.write("\n");
582
+ }
36
583
  }
37
584
 
38
585
  // src/client/session/cli.ts
39
586
  import { spawn } from "child_process";
40
- import { existsSync, readFileSync, rmSync as rmSync2 } from "fs";
587
+ import { existsSync, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
41
588
 
42
589
  // src/client/session/daemon.ts
43
- import { appendFileSync, chmodSync, rmSync } from "fs";
590
+ import { appendFileSync as appendFileSync2, chmodSync, rmSync } from "fs";
44
591
  import { createServer } from "net";
45
- import { createInterface } from "readline";
592
+ import { createInterface as createInterface3 } from "readline";
593
+ var RELOAD = "app.reload";
594
+ var pause = (ms, signal) => new Promise((resolve2) => {
595
+ const done = () => {
596
+ clearTimeout(timer);
597
+ signal.removeEventListener("abort", done);
598
+ resolve2();
599
+ };
600
+ const timer = setTimeout(done, ms);
601
+ signal.addEventListener("abort", done, { once: true });
602
+ });
46
603
  var message = (error) => error instanceof Error ? error.message : String(error);
47
604
  var lostConnection = (r) => !r.ok && (r.from === "" || /socket closed|closed the debugger|not open/i.test(r.error));
48
605
  async function runSessionDaemon(options) {
@@ -51,17 +608,22 @@ async function runSessionDaemon(options) {
51
608
  const files = sessionFiles(name, dir);
52
609
  const metro = metroHost(options.metro);
53
610
  const timeoutMs = options.timeoutMs ?? 1e4;
54
- const log = (text) => appendFileSync(files.log, `${(/* @__PURE__ */ new Date()).toISOString()} ${text}
611
+ const reloadWaitMs = options.reloadWaitMs ?? 2e4;
612
+ const log = (text) => appendFileSync2(files.log, `${(/* @__PURE__ */ new Date()).toISOString()} ${text}
55
613
  `, {
56
614
  mode: 384
57
615
  });
58
616
  const running = readSession(name, dir);
59
617
  if (running)
60
618
  throw new Error(`Session "${name}" is already running (pid ${running.pid})`);
61
- const reach = () => openConnection({
619
+ const attemptMs = options.attemptMs ?? 12e3;
620
+ const stopping = new AbortController();
621
+ const cutOff = new AbortController();
622
+ const reach = (signal) => openConnection({
62
623
  metro,
63
624
  device: options.device,
64
- transport: options.transport
625
+ transport: options.transport,
626
+ ...signal && { signal, timeoutMs: attemptMs }
65
627
  });
66
628
  let conn = await reach();
67
629
  const owner = listSessions(dir).find(
@@ -83,6 +645,7 @@ async function runSessionDaemon(options) {
83
645
  pid: process.pid,
84
646
  socket: files.socket,
85
647
  metro,
648
+ project: options.project ?? projectRoot(),
86
649
  device: deviceOf(conn),
87
650
  deviceFilter: options.device,
88
651
  transport: conn.transport,
@@ -93,22 +656,64 @@ async function runSessionDaemon(options) {
93
656
  const save = () => {
94
657
  if (!finishing) writePrivate(files.state, JSON.stringify(state, null, 2));
95
658
  };
659
+ let knownLoad = conn.device.loadId;
660
+ let lastPending = [];
661
+ const reloads = [];
662
+ let unseen = [];
663
+ const noteReload = () => {
664
+ const lost = lastPending;
665
+ lastPending = [];
666
+ const note = `app reloaded; ${lost.length} pending restore${lost.length === 1 ? "" : "s"} lost${lost.length ? `: ${lost.join(", ")}` : ""}`;
667
+ reloads.push(note);
668
+ unseen.push(note);
669
+ log(`warning: ${note}`);
670
+ };
671
+ const observe = (r) => {
672
+ if (!r.loadId) return;
673
+ if (knownLoad && r.loadId !== knownLoad) noteReload();
674
+ knownLoad = r.loadId;
675
+ lastPending = r.pending ?? [];
676
+ };
677
+ const takeNotice = () => {
678
+ const notice = unseen.join("; ");
679
+ unseen = [];
680
+ return notice ? { notice } : {};
681
+ };
682
+ let reloadingUntil = 0;
96
683
  let stale = false;
97
684
  let reconnecting = null;
98
685
  const reconnect = (reason) => {
99
686
  reconnecting ??= (async () => {
100
687
  log(`reconnecting: ${reason}`);
101
688
  conn.close();
102
- try {
103
- conn = await reach();
104
- } catch (error) {
105
- stale = true;
106
- log(`reconnect failed: ${message(error)}`);
107
- throw new Error(
108
- `The app is gone (${reason}). Reconnecting failed: ${message(error)}`
109
- );
689
+ const patient = Date.now() < reloadingUntil;
690
+ const deadline = Date.now() + (patient ? reloadWaitMs : 0);
691
+ for (; ; ) {
692
+ try {
693
+ const next = await reach(cutOff.signal);
694
+ if (patient && knownLoad && next.device.loadId === knownLoad) {
695
+ next.close();
696
+ throw new Error("the old runtime still answers");
697
+ }
698
+ conn = next;
699
+ break;
700
+ } catch (error) {
701
+ if (stopping.signal.aborted || Date.now() >= deadline) {
702
+ stale = true;
703
+ log(`reconnect failed: ${message(error)}`);
704
+ throw new Error(
705
+ patient ? `The app is reloading; its bridge isn't ready yet (${message(error)})` : `The app is gone (${reason}). Reconnecting failed: ${message(error)}`
706
+ );
707
+ }
708
+ await pause(300, stopping.signal);
709
+ }
110
710
  }
111
711
  stale = false;
712
+ reloadingUntil = 0;
713
+ if (conn.device.loadId) {
714
+ if (knownLoad && conn.device.loadId !== knownLoad) noteReload();
715
+ knownLoad = conn.device.loadId;
716
+ }
112
717
  state.device = deviceOf(conn);
113
718
  state.transport = conn.transport;
114
719
  save();
@@ -118,8 +723,33 @@ async function runSessionDaemon(options) {
118
723
  });
119
724
  return reconnecting;
120
725
  };
726
+ const connected = async (reason = "app stopped answering") => {
727
+ const attempt2 = reconnecting ?? reconnect(reason);
728
+ const wait = Date.now() < reloadingUntil ? reloadWaitMs + 2 * attemptMs + 500 : 2 * attemptMs + 500;
729
+ let timer2;
730
+ try {
731
+ await Promise.race([
732
+ attempt2,
733
+ new Promise((_, reject) => {
734
+ timer2 = setTimeout(
735
+ () => reject(
736
+ new Error(
737
+ "app not connected; reconnecting. Try again in a moment"
738
+ )
739
+ ),
740
+ wait
741
+ );
742
+ })
743
+ ]);
744
+ } finally {
745
+ clearTimeout(timer2);
746
+ }
747
+ };
121
748
  const ping = (ms = 1500) => conn.call("bridge.ping", [], Math.min(ms, 1500)).then(
122
- (r) => r.ok,
749
+ (r) => {
750
+ if (r.ok) observe(r);
751
+ return r.ok;
752
+ },
123
753
  () => false
124
754
  );
125
755
  const attempt = async (tool, args, ms) => {
@@ -132,25 +762,43 @@ async function runSessionDaemon(options) {
132
762
  }
133
763
  };
134
764
  const callApp = async (tool, args, ms) => {
135
- if (stale || reconnecting)
136
- await (reconnecting ?? reconnect("app stopped answering"));
765
+ if (stale || reconnecting) await connected();
137
766
  let t0 = performance.now();
138
767
  const first = await attempt(tool, args, ms);
139
- if (first.result)
768
+ if (first.result) {
769
+ observe(first.result);
770
+ if (tool === RELOAD && first.result.ok) waitForReload();
140
771
  return { result: first.result, ms: performance.now() - t0 };
141
- await reconnect(first.lost);
772
+ }
773
+ if (tool === RELOAD) {
774
+ waitForReload();
775
+ const result = {
776
+ id: "",
777
+ from: "",
778
+ ok: true,
779
+ value: { reloading: true },
780
+ ms: 0
781
+ };
782
+ return { result, ms: performance.now() - t0 };
783
+ }
784
+ await connected(first.lost);
142
785
  t0 = performance.now();
143
786
  const second = await attempt(tool, args, ms);
144
787
  if (!second.result)
145
788
  throw new Error(
146
789
  `The app stopped answering after a reconnect: ${second.lost}`
147
790
  );
791
+ observe(second.result);
148
792
  return {
149
793
  result: second.result,
150
794
  ms: performance.now() - t0,
151
795
  reconnected: true
152
796
  };
153
797
  };
798
+ const waitForReload = () => {
799
+ reloadingUntil = Date.now() + reloadWaitMs;
800
+ stale = true;
801
+ };
154
802
  let inflight = 0;
155
803
  let idleTimer;
156
804
  const armIdle = () => {
@@ -185,6 +833,8 @@ async function runSessionDaemon(options) {
185
833
  finishing ??= (async () => {
186
834
  clearTimeout(idleTimer);
187
835
  clearInterval(health);
836
+ stopping.abort();
837
+ const cut = setTimeout(() => cutOff.abort(), Math.min(attemptMs, 5e3));
188
838
  let restore = null;
189
839
  if (!keep) {
190
840
  restore = await callApp("bridge.restore", [], timeoutMs).then(
@@ -198,6 +848,8 @@ async function runSessionDaemon(options) {
198
848
  })
199
849
  );
200
850
  }
851
+ clearTimeout(cut);
852
+ cutOff.abort();
201
853
  conn.close();
202
854
  server.close();
203
855
  removeSessionFiles(name, dir);
@@ -213,6 +865,14 @@ async function runSessionDaemon(options) {
213
865
  })();
214
866
  return finishing;
215
867
  };
868
+ const readPending = async () => {
869
+ try {
870
+ const { result } = await callApp("bridge.pending", [], timeoutMs);
871
+ return result.ok ? result.value : void 0;
872
+ } catch {
873
+ return void 0;
874
+ }
875
+ };
216
876
  const handle = async (req) => {
217
877
  if (finishing && req.op !== "stop")
218
878
  return { id: req.id, error: `Session "${name}" is stopping` };
@@ -222,14 +882,14 @@ async function runSessionDaemon(options) {
222
882
  inflight++;
223
883
  touch();
224
884
  try {
225
- return {
226
- id: req.id,
227
- ...await callApp(
228
- req.tool,
229
- req.args ?? [],
230
- req.timeoutMs ?? timeoutMs
231
- )
232
- };
885
+ const answer = await callApp(
886
+ req.tool,
887
+ req.args ?? [],
888
+ req.timeoutMs ?? timeoutMs
889
+ );
890
+ return { id: req.id, ...answer, ...takeNotice() };
891
+ } catch (error) {
892
+ return { id: req.id, error: message(error), ...takeNotice() };
233
893
  } finally {
234
894
  inflight--;
235
895
  touch();
@@ -237,8 +897,7 @@ async function runSessionDaemon(options) {
237
897
  }
238
898
  case "tools":
239
899
  touch();
240
- if (stale || reconnecting)
241
- await (reconnecting ?? reconnect("app stopped answering"));
900
+ if (stale || reconnecting) await connected();
242
901
  return { id: req.id, device: conn.device, transport: conn.transport };
243
902
  case "info":
244
903
  return {
@@ -247,8 +906,18 @@ async function runSessionDaemon(options) {
247
906
  device: conn.device,
248
907
  transport: conn.transport
249
908
  };
250
- case "stop":
251
- return { id: req.id, restore: await teardown("stop", !!req.keep) };
909
+ case "stop": {
910
+ if (req.dryRun && finishing)
911
+ return { id: req.id, error: `Session "${name}" is stopping` };
912
+ const pending = req.dryRun || req.keep ? await readPending() : void 0;
913
+ if (req.dryRun) return { id: req.id, pending };
914
+ return {
915
+ id: req.id,
916
+ restore: await teardown("stop", !!req.keep),
917
+ ...pending && { pending },
918
+ ...reloads.length && { notice: reloads.join("; ") }
919
+ };
920
+ }
252
921
  default:
253
922
  return { id: req.id, error: `Unknown op "${String(req.op)}"` };
254
923
  }
@@ -262,7 +931,7 @@ async function runSessionDaemon(options) {
262
931
  socket.on("close", () => clients.delete(socket));
263
932
  socket.on("error", () => {
264
933
  });
265
- createInterface({ input: socket }).on("line", async (line) => {
934
+ createInterface3({ input: socket }).on("line", async (line) => {
266
935
  let req;
267
936
  try {
268
937
  req = JSON.parse(line);
@@ -273,9 +942,8 @@ async function runSessionDaemon(options) {
273
942
  id: req.id,
274
943
  error: message(error)
275
944
  }));
276
- if (!socket.writableEnded) socket.write(`${JSON.stringify(res)}
277
- `);
278
- if (req.op === "stop") closeClients();
945
+ if (!socket.writableEnded) socket.write(encodeLine(res));
946
+ if (req.op === "stop" && !req.dryRun) closeClients();
279
947
  });
280
948
  });
281
949
  if (process.platform !== "win32") rmSync(files.socket, { force: true });
@@ -294,6 +962,7 @@ async function runSessionDaemon(options) {
294
962
  }
295
963
  if (process.platform !== "win32") chmodSync(files.socket, 384);
296
964
  touch();
965
+ await ping();
297
966
  log(
298
967
  `started: ${conn.device.name} via ${conn.transport} on ${metro}, pid ${process.pid}, idle ${options.idleMs} ms`
299
968
  );
@@ -314,7 +983,7 @@ function sessionFor(flags) {
314
983
  if (flags["no-session"]) return null;
315
984
  return pickSession({
316
985
  name: flags.session ?? process.env.AGENT_BRIDGE_SESSION,
317
- metro: metroHost(flags.metro),
986
+ metro: explicitMetro(flags.metro),
318
987
  device: flags.device,
319
988
  transport: flags.transport
320
989
  });
@@ -385,7 +1054,7 @@ async function start(flags) {
385
1054
  } else if (failed || exited || Date.now() > deadline) {
386
1055
  clearInterval(tick);
387
1056
  if (!exited) child.kill();
388
- const reason = failed ? readFileSync(files.error, "utf8") : exited ? `The session exited early; see ${files.log}` : `The session didn't connect within 30 s; see ${files.log}`;
1057
+ const reason = failed ? readFileSync3(files.error, "utf8") : exited ? `The session exited early; see ${files.log}` : `The session didn't connect within 30 s; see ${files.log}`;
389
1058
  rmSync2(files.error, { force: true });
390
1059
  reject(new Error(reason));
391
1060
  }
@@ -397,23 +1066,54 @@ async function start(flags) {
397
1066
  `Session "${state.name}": ${state.device.name} via ${state.transport}${idle}`
398
1067
  );
399
1068
  console.log(
400
- "`call`, `tools` and `run` go through it; `agent-bridge session stop` undoes and ends it."
1069
+ "`call`, `tools`, `repl` and `run` go through it; `agent-bridge session stop` undoes and ends it."
401
1070
  );
402
1071
  }
1072
+ var describePending = (pending) => Object.entries(pending).map(
1073
+ ([area, detail]) => detail === true ? ` ${area}` : ` ${area}: ${JSON.stringify(detail)}`
1074
+ ).join("\n");
403
1075
  async function stop(flags) {
404
1076
  const state = chosen(flags);
1077
+ const dryRun = !!flags["dry-run"];
405
1078
  const res = await sessionRequest(state, {
406
1079
  op: "stop",
407
- keep: !!flags.keep
1080
+ keep: !!flags.keep,
1081
+ dryRun
408
1082
  }).catch(() => null);
1083
+ if (dryRun && (!res || res.error)) {
1084
+ console.log(
1085
+ `Dry run failed: ${res?.error ?? `session "${state.name}" didn't answer`}. Nothing was stopped or restored; check \`agent-bridge session list\`.`
1086
+ );
1087
+ return;
1088
+ }
409
1089
  if (!res || res.error) {
410
1090
  if (isAlive(state.pid)) process.kill(state.pid, "SIGTERM");
411
1091
  console.log(`Session "${state.name}" didn't answer; sent it SIGTERM.`);
412
1092
  return;
413
1093
  }
1094
+ if (dryRun && res.restore !== void 0) {
1095
+ console.log(
1096
+ `Stopped session "${state.name}": its daemon predates --dry-run, so it stopped and did not just list. ${describeRestore(res.restore)}`
1097
+ );
1098
+ return;
1099
+ }
1100
+ if (dryRun) {
1101
+ const areas = Object.keys(res.pending ?? {});
1102
+ console.log(
1103
+ !res.pending ? `Session "${state.name}" is still running. The app has no bridge.pending, so nothing can be listed.` : areas.length ? `Session "${state.name}" is still running. bridge.restore would undo:
1104
+ ${describePending(res.pending)}` : `Session "${state.name}" is still running. Nothing is pending; bridge.restore would undo nothing.`
1105
+ );
1106
+ return;
1107
+ }
414
1108
  console.log(
415
1109
  `Stopped session "${state.name}". ${describeRestore(res.restore)}`
416
1110
  );
1111
+ if (res.pending && Object.keys(res.pending).length)
1112
+ console.log(
1113
+ `Warning: still pending, and a later bridge.restore will undo it (run store.commit to keep a store as it is):
1114
+ ${describePending(res.pending)}`
1115
+ );
1116
+ if (res.notice) console.log(`Warning: ${res.notice}`);
417
1117
  }
418
1118
  function list() {
419
1119
  const sessions = listSessions();
@@ -473,42 +1173,60 @@ async function daemonMain(flags) {
473
1173
  }
474
1174
 
475
1175
  // src/cli.ts
476
- var HELP = `agent-bridge: drive a running React Native app from an agent
1176
+ var HELP2 = `agent-bridge: drive a running React Native app from an agent
477
1177
 
478
1178
  Usage
479
1179
  agent-bridge devices Apps connected to Metro
480
1180
  agent-bridge tools Tools the app exposes
481
1181
  agent-bridge call <tool> [args] Call a tool. args: a JSON array, or one JSON value
1182
+ agent-bridge call <tool> @args.json Same, read from a file (@- is stdin): no argv size limit
1183
+ agent-bridge call <tool> '"/feed"' @feed.json
1184
+ With several words each is one argument; @file is
1185
+ that file's JSON. A string starting with @: '"@user"'
1186
+ agent-bridge call --batch Read \`tool args\` lines from stdin (args may be @file); one JSON line per call, one connection
1187
+ agent-bridge repl Interactive prompt (history, tab completion, .help). With
1188
+ stdin not a terminal it behaves like call --batch
482
1189
  agent-bridge run <flow.mjs|.ts> Run a flow: export default async ({ step, call }) => {}
1190
+ export const scenario = 'signedIn' applies it first
1191
+ and runs bridge.restore after, even on failure
1192
+ agent-bridge scenarios Scenarios the app defines (scenario.apply applies one)
483
1193
  agent-bridge assert-absent <files...> Fail if a release bundle contains the bridge
484
1194
 
485
1195
  Sessions: one connection for all of an agent's calls
486
1196
  agent-bridge session start [--name n] [--idle 15m] Connect once in the background
487
1197
  agent-bridge session stop [--name n] [--keep] bridge.restore (unless --keep), then end
1198
+ agent-bridge session stop --dry-run List what bridge.restore would undo; keep running
488
1199
  agent-bridge session list | status [--name n]
489
- While a session matches --metro/--device, call, tools and run use it.
1200
+ call, tools, repl and run use this project's running session (the only one, or the one
1201
+ matching --metro/--device), and print its name. With several, pass --session <name>.
490
1202
 
491
1203
  Options
492
1204
  --metro <host:port> Metro dev server (env AGENT_BRIDGE_METRO, default localhost:8081)
493
1205
  --device <text> Pick an app when several are connected
494
1206
  --transport <name> auto (default), expo or cdp
495
1207
  --timeout <ms> Per-call timeout (default 10000)
1208
+ --out <file> call: write the result to a file; print its size and shape
1209
+ (--batch: a directory, one <n>-<tool>.json per call)
1210
+ --stop-on-error call --batch: stop after the first failed call
1211
+ --full call: print results over 32 KB instead of a summary
496
1212
  --strict run: exit non-zero if the app logged an error
1213
+ --scenario <name> run: also apply this scenario (repeatable). Options as
1214
+ JSON after "=": --scenario 'signedIn={"user":{"name":"Ada"}}'
497
1215
  --session <name> Use this session (env AGENT_BRIDGE_SESSION)
498
1216
  --no-session Connect directly even if a session is running
499
1217
  `;
500
1218
  var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
501
- function parseCallArgs(raw) {
502
- if (raw === void 0) return [];
503
- let value;
504
- try {
505
- value = JSON.parse(raw);
506
- } catch {
507
- return [raw];
508
- }
509
- return Array.isArray(value) ? value : [value];
1219
+ async function readStdin() {
1220
+ const chunks = [];
1221
+ for await (const chunk of process.stdin) chunks.push(chunk);
1222
+ return Buffer.concat(chunks).toString("utf8");
510
1223
  }
511
1224
  async function main() {
1225
+ process.on("uncaughtException", (error) => {
1226
+ if (error.code === "EPIPE") process.exit(process.exitCode ?? 0);
1227
+ console.error(error);
1228
+ process.exit(1);
1229
+ });
512
1230
  const { values, positionals } = parseArgs({
513
1231
  allowPositionals: true,
514
1232
  options: {
@@ -517,9 +1235,15 @@ async function main() {
517
1235
  transport: { type: "string" },
518
1236
  timeout: { type: "string" },
519
1237
  strict: { type: "boolean" },
1238
+ out: { type: "string" },
1239
+ full: { type: "boolean" },
1240
+ batch: { type: "boolean" },
1241
+ "stop-on-error": { type: "boolean" },
1242
+ scenario: { type: "string", multiple: true },
520
1243
  name: { type: "string" },
521
1244
  idle: { type: "string" },
522
1245
  keep: { type: "boolean" },
1246
+ "dry-run": { type: "boolean" },
523
1247
  session: { type: "string" },
524
1248
  "no-session": { type: "boolean" },
525
1249
  help: { type: "boolean", short: "h" }
@@ -527,7 +1251,7 @@ async function main() {
527
1251
  });
528
1252
  const [command, ...rest] = positionals;
529
1253
  if (!command || values.help) {
530
- process.stdout.write(HELP);
1254
+ process.stdout.write(HELP2);
531
1255
  return;
532
1256
  }
533
1257
  const options = {
@@ -538,6 +1262,7 @@ async function main() {
538
1262
  };
539
1263
  const withBridge = async (fn) => {
540
1264
  const session = sessionFor(values);
1265
+ if (session) console.error(`Using session "${session.name}"`);
541
1266
  const bridge = session ? await connectSession({
542
1267
  name: session.name,
543
1268
  timeoutMs: options.timeoutMs
@@ -548,6 +1273,14 @@ async function main() {
548
1273
  bridge.close();
549
1274
  }
550
1275
  };
1276
+ const batch = async (bridge) => {
1277
+ const { failed } = await runBatch(bridge, process.stdin, {
1278
+ stopOnError: values["stop-on-error"],
1279
+ full: values.full,
1280
+ outDir: values.out
1281
+ });
1282
+ if (failed) process.exitCode = 1;
1283
+ };
551
1284
  switch (command) {
552
1285
  case "devices": {
553
1286
  const devices = await listDevices(options);
@@ -565,65 +1298,82 @@ async function main() {
565
1298
  console.log(` ${t.name.padEnd(22)} ${t.description ?? ""}`);
566
1299
  });
567
1300
  case "call": {
568
- const [tool, raw] = rest;
1301
+ if (values.batch) {
1302
+ if (rest.length)
1303
+ throw new Error("call --batch reads calls from stdin; pass no tool");
1304
+ return withBridge((bridge) => batch(bridge));
1305
+ }
1306
+ const [tool, ...words] = rest;
569
1307
  if (!tool) throw new Error("Usage: agent-bridge call <tool> [args]");
1308
+ if (words.filter((w) => w === "@-").length > 1)
1309
+ throw new Error("Only one argument can be @- (stdin)");
1310
+ const stdin = words.includes("@-") ? await readStdin() : "";
1311
+ const args = parseCallArgv(
1312
+ words,
1313
+ (path) => path === "-" ? stdin : readArgFile(path)
1314
+ );
570
1315
  return withBridge(async (bridge) => {
571
- const { value, ms, appMs, logs } = await bridge.timed(tool, ...parseCallArgs(raw)).catch((error) => {
1316
+ const { value, ms, appMs, logs, notice } = await bridge.timed(tool, ...args).catch((error) => {
572
1317
  for (const e of failedLogs(error)) console.error(logLine(e));
573
1318
  throw error;
574
1319
  });
575
- console.log(JSON.stringify(value, null, 2));
1320
+ console.log(
1321
+ await renderResult(value, { out: values.out, full: values.full })
1322
+ );
576
1323
  for (const e of logs) console.error(logLine(e));
1324
+ if (notice) console.error(`Warning: ${notice}`);
577
1325
  console.error(
578
1326
  `${tool} via ${bridge.transport}: ${ms.toFixed(1)} ms round trip, ${appMs} ms in the app`
579
1327
  );
580
1328
  });
581
1329
  }
1330
+ case "repl":
1331
+ if (values.out)
1332
+ throw new Error(
1333
+ "repl has no --out; use `call --batch --out <dir>` to write results to files"
1334
+ );
1335
+ return withBridge(async (bridge) => {
1336
+ if (!process.stdin.isTTY) return batch(bridge);
1337
+ await runRepl(bridge, {
1338
+ input: process.stdin,
1339
+ output: process.stdout,
1340
+ tty: !!process.stdout.isTTY,
1341
+ historyFile: DEFAULT_HISTORY_FILE,
1342
+ full: values.full
1343
+ });
1344
+ });
582
1345
  case "run": {
583
1346
  const [file] = rest;
584
1347
  if (!file) throw new Error("Usage: agent-bridge run <flow file>");
585
1348
  const flow = await import(pathToFileURL(resolve(file)).href);
1349
+ flowScenarios(flow);
1350
+ const extra = (values.scenario ?? []).map(parseScenarioFlag);
586
1351
  return withBridge(async (bridge) => {
587
- let n = 0;
588
- let total = 0;
589
- let errors = 0;
590
- const t0 = performance.now();
591
- const report = (logs) => {
592
- errors += logs.length;
593
- for (const e of logs) console.log(` ${logLine(e)}`);
594
- };
595
- const timed = async (tool, ...args) => {
596
- try {
597
- const result = await bridge.timed(tool, ...args);
598
- report(result.logs);
599
- return result;
600
- } catch (error) {
601
- report(failedLogs(error));
602
- throw error;
603
- }
604
- };
605
- const call = async (tool, ...args) => (await timed(tool, ...args)).value;
606
- const step = async (label, tool, ...args) => {
607
- const { value, ms, logs } = await bridge.timed(tool, ...args).catch(
608
- (error) => {
609
- report(failedLogs(error));
610
- throw error;
611
- }
1352
+ const { errors, restoreErrors } = await runFlow(bridge, flow, {
1353
+ scenarios: extra
1354
+ });
1355
+ if (values.strict && errors || restoreErrors.length)
1356
+ process.exitCode = 1;
1357
+ });
1358
+ }
1359
+ case "scenarios":
1360
+ return withBridge(async (bridge) => {
1361
+ if (!bridge.tools().some((t) => t.name === "scenario.list")) {
1362
+ console.log(
1363
+ `${bridge.device.name} defines no scenarios (pass \`scenarios\` to useAgentBridge).`
612
1364
  );
613
- total += ms;
1365
+ return;
1366
+ }
1367
+ const list2 = await bridge.call("scenario.list");
1368
+ console.log(`${bridge.device.name} via ${bridge.transport}`);
1369
+ for (const s of list2) {
614
1370
  console.log(
615
- `${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
1371
+ ` ${s.name.padEnd(20)} ${s.active ? "(active) " : ""}${s.description ?? ""}`
616
1372
  );
617
- report(logs);
618
- return value;
619
- };
620
- await flow.default({ bridge: { ...bridge, timed, call }, call, step });
621
- console.log(
622
- `${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
623
- );
624
- if (values.strict && errors) process.exitCode = 1;
1373
+ if (s.options)
1374
+ console.log(` ${"".padEnd(20)} options: ${JSON.stringify(s.options)}`);
1375
+ }
625
1376
  });
626
- }
627
1377
  case "session":
628
1378
  return sessionCommand(rest[0], values);
629
1379
  case DAEMON_COMMAND:
@@ -648,7 +1398,7 @@ async function main() {
648
1398
  default:
649
1399
  throw new Error(`Unknown command "${command}".
650
1400
 
651
- ${HELP}`);
1401
+ ${HELP2}`);
652
1402
  }
653
1403
  }
654
1404
  main().catch((error) => {