@avasapp/agent-bridge 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,6 +5,8 @@ import {
5
5
  checkName,
6
6
  connect,
7
7
  connectSession,
8
+ encodeLine,
9
+ explicitMetro,
8
10
  flowScenarios,
9
11
  formatDuration,
10
12
  isAlive,
@@ -16,6 +18,7 @@ import {
16
18
  parseDuration,
17
19
  parseScenarioFlag,
18
20
  pickSession,
21
+ projectRoot,
19
22
  readSession,
20
23
  removeSessionFiles,
21
24
  runFlow,
@@ -23,7 +26,7 @@ import {
23
26
  sessionRequest,
24
27
  stateDir,
25
28
  writePrivate
26
- } from "./chunk-S2FYN2R5.js";
29
+ } from "./chunk-AJD3XTNT.js";
27
30
 
28
31
  // src/cli.ts
29
32
  import { readFile } from "fs/promises";
@@ -31,14 +34,572 @@ import { resolve } from "path";
31
34
  import { pathToFileURL } from "url";
32
35
  import { parseArgs } from "util";
33
36
 
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
+ }
583
+ }
584
+
34
585
  // src/client/session/cli.ts
35
586
  import { spawn } from "child_process";
36
- import { existsSync, readFileSync, rmSync as rmSync2 } from "fs";
587
+ import { existsSync, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
37
588
 
38
589
  // src/client/session/daemon.ts
39
- import { appendFileSync, chmodSync, rmSync } from "fs";
590
+ import { appendFileSync as appendFileSync2, chmodSync, rmSync } from "fs";
40
591
  import { createServer } from "net";
41
- 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
+ });
42
603
  var message = (error) => error instanceof Error ? error.message : String(error);
43
604
  var lostConnection = (r) => !r.ok && (r.from === "" || /socket closed|closed the debugger|not open/i.test(r.error));
44
605
  async function runSessionDaemon(options) {
@@ -47,17 +608,22 @@ async function runSessionDaemon(options) {
47
608
  const files = sessionFiles(name, dir);
48
609
  const metro = metroHost(options.metro);
49
610
  const timeoutMs = options.timeoutMs ?? 1e4;
50
- 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}
51
613
  `, {
52
614
  mode: 384
53
615
  });
54
616
  const running = readSession(name, dir);
55
617
  if (running)
56
618
  throw new Error(`Session "${name}" is already running (pid ${running.pid})`);
57
- const reach = () => openConnection({
619
+ const attemptMs = options.attemptMs ?? 12e3;
620
+ const stopping = new AbortController();
621
+ const cutOff = new AbortController();
622
+ const reach = (signal) => openConnection({
58
623
  metro,
59
624
  device: options.device,
60
- transport: options.transport
625
+ transport: options.transport,
626
+ ...signal && { signal, timeoutMs: attemptMs }
61
627
  });
62
628
  let conn = await reach();
63
629
  const owner = listSessions(dir).find(
@@ -79,6 +645,7 @@ async function runSessionDaemon(options) {
79
645
  pid: process.pid,
80
646
  socket: files.socket,
81
647
  metro,
648
+ project: options.project ?? projectRoot(),
82
649
  device: deviceOf(conn),
83
650
  deviceFilter: options.device,
84
651
  transport: conn.transport,
@@ -89,22 +656,64 @@ async function runSessionDaemon(options) {
89
656
  const save = () => {
90
657
  if (!finishing) writePrivate(files.state, JSON.stringify(state, null, 2));
91
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;
92
683
  let stale = false;
93
684
  let reconnecting = null;
94
685
  const reconnect = (reason) => {
95
686
  reconnecting ??= (async () => {
96
687
  log(`reconnecting: ${reason}`);
97
688
  conn.close();
98
- try {
99
- conn = await reach();
100
- } catch (error) {
101
- stale = true;
102
- log(`reconnect failed: ${message(error)}`);
103
- throw new Error(
104
- `The app is gone (${reason}). Reconnecting failed: ${message(error)}`
105
- );
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
+ }
106
710
  }
107
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
+ }
108
717
  state.device = deviceOf(conn);
109
718
  state.transport = conn.transport;
110
719
  save();
@@ -114,8 +723,33 @@ async function runSessionDaemon(options) {
114
723
  });
115
724
  return reconnecting;
116
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
+ };
117
748
  const ping = (ms = 1500) => conn.call("bridge.ping", [], Math.min(ms, 1500)).then(
118
- (r) => r.ok,
749
+ (r) => {
750
+ if (r.ok) observe(r);
751
+ return r.ok;
752
+ },
119
753
  () => false
120
754
  );
121
755
  const attempt = async (tool, args, ms) => {
@@ -128,25 +762,43 @@ async function runSessionDaemon(options) {
128
762
  }
129
763
  };
130
764
  const callApp = async (tool, args, ms) => {
131
- if (stale || reconnecting)
132
- await (reconnecting ?? reconnect("app stopped answering"));
765
+ if (stale || reconnecting) await connected();
133
766
  let t0 = performance.now();
134
767
  const first = await attempt(tool, args, ms);
135
- if (first.result)
768
+ if (first.result) {
769
+ observe(first.result);
770
+ if (tool === RELOAD && first.result.ok) waitForReload();
136
771
  return { result: first.result, ms: performance.now() - t0 };
137
- 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);
138
785
  t0 = performance.now();
139
786
  const second = await attempt(tool, args, ms);
140
787
  if (!second.result)
141
788
  throw new Error(
142
789
  `The app stopped answering after a reconnect: ${second.lost}`
143
790
  );
791
+ observe(second.result);
144
792
  return {
145
793
  result: second.result,
146
794
  ms: performance.now() - t0,
147
795
  reconnected: true
148
796
  };
149
797
  };
798
+ const waitForReload = () => {
799
+ reloadingUntil = Date.now() + reloadWaitMs;
800
+ stale = true;
801
+ };
150
802
  let inflight = 0;
151
803
  let idleTimer;
152
804
  const armIdle = () => {
@@ -181,6 +833,8 @@ async function runSessionDaemon(options) {
181
833
  finishing ??= (async () => {
182
834
  clearTimeout(idleTimer);
183
835
  clearInterval(health);
836
+ stopping.abort();
837
+ const cut = setTimeout(() => cutOff.abort(), Math.min(attemptMs, 5e3));
184
838
  let restore = null;
185
839
  if (!keep) {
186
840
  restore = await callApp("bridge.restore", [], timeoutMs).then(
@@ -194,6 +848,8 @@ async function runSessionDaemon(options) {
194
848
  })
195
849
  );
196
850
  }
851
+ clearTimeout(cut);
852
+ cutOff.abort();
197
853
  conn.close();
198
854
  server.close();
199
855
  removeSessionFiles(name, dir);
@@ -209,6 +865,14 @@ async function runSessionDaemon(options) {
209
865
  })();
210
866
  return finishing;
211
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
+ };
212
876
  const handle = async (req) => {
213
877
  if (finishing && req.op !== "stop")
214
878
  return { id: req.id, error: `Session "${name}" is stopping` };
@@ -218,14 +882,14 @@ async function runSessionDaemon(options) {
218
882
  inflight++;
219
883
  touch();
220
884
  try {
221
- return {
222
- id: req.id,
223
- ...await callApp(
224
- req.tool,
225
- req.args ?? [],
226
- req.timeoutMs ?? timeoutMs
227
- )
228
- };
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() };
229
893
  } finally {
230
894
  inflight--;
231
895
  touch();
@@ -233,8 +897,7 @@ async function runSessionDaemon(options) {
233
897
  }
234
898
  case "tools":
235
899
  touch();
236
- if (stale || reconnecting)
237
- await (reconnecting ?? reconnect("app stopped answering"));
900
+ if (stale || reconnecting) await connected();
238
901
  return { id: req.id, device: conn.device, transport: conn.transport };
239
902
  case "info":
240
903
  return {
@@ -243,8 +906,18 @@ async function runSessionDaemon(options) {
243
906
  device: conn.device,
244
907
  transport: conn.transport
245
908
  };
246
- case "stop":
247
- 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
+ }
248
921
  default:
249
922
  return { id: req.id, error: `Unknown op "${String(req.op)}"` };
250
923
  }
@@ -258,7 +931,7 @@ async function runSessionDaemon(options) {
258
931
  socket.on("close", () => clients.delete(socket));
259
932
  socket.on("error", () => {
260
933
  });
261
- createInterface({ input: socket }).on("line", async (line) => {
934
+ createInterface3({ input: socket }).on("line", async (line) => {
262
935
  let req;
263
936
  try {
264
937
  req = JSON.parse(line);
@@ -269,9 +942,8 @@ async function runSessionDaemon(options) {
269
942
  id: req.id,
270
943
  error: message(error)
271
944
  }));
272
- if (!socket.writableEnded) socket.write(`${JSON.stringify(res)}
273
- `);
274
- if (req.op === "stop") closeClients();
945
+ if (!socket.writableEnded) socket.write(encodeLine(res));
946
+ if (req.op === "stop" && !req.dryRun) closeClients();
275
947
  });
276
948
  });
277
949
  if (process.platform !== "win32") rmSync(files.socket, { force: true });
@@ -290,6 +962,7 @@ async function runSessionDaemon(options) {
290
962
  }
291
963
  if (process.platform !== "win32") chmodSync(files.socket, 384);
292
964
  touch();
965
+ await ping();
293
966
  log(
294
967
  `started: ${conn.device.name} via ${conn.transport} on ${metro}, pid ${process.pid}, idle ${options.idleMs} ms`
295
968
  );
@@ -310,7 +983,7 @@ function sessionFor(flags) {
310
983
  if (flags["no-session"]) return null;
311
984
  return pickSession({
312
985
  name: flags.session ?? process.env.AGENT_BRIDGE_SESSION,
313
- metro: metroHost(flags.metro),
986
+ metro: explicitMetro(flags.metro),
314
987
  device: flags.device,
315
988
  transport: flags.transport
316
989
  });
@@ -381,7 +1054,7 @@ async function start(flags) {
381
1054
  } else if (failed || exited || Date.now() > deadline) {
382
1055
  clearInterval(tick);
383
1056
  if (!exited) child.kill();
384
- 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}`;
385
1058
  rmSync2(files.error, { force: true });
386
1059
  reject(new Error(reason));
387
1060
  }
@@ -393,23 +1066,54 @@ async function start(flags) {
393
1066
  `Session "${state.name}": ${state.device.name} via ${state.transport}${idle}`
394
1067
  );
395
1068
  console.log(
396
- "`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."
397
1070
  );
398
1071
  }
1072
+ var describePending = (pending) => Object.entries(pending).map(
1073
+ ([area, detail]) => detail === true ? ` ${area}` : ` ${area}: ${JSON.stringify(detail)}`
1074
+ ).join("\n");
399
1075
  async function stop(flags) {
400
1076
  const state = chosen(flags);
1077
+ const dryRun = !!flags["dry-run"];
401
1078
  const res = await sessionRequest(state, {
402
1079
  op: "stop",
403
- keep: !!flags.keep
1080
+ keep: !!flags.keep,
1081
+ dryRun
404
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
+ }
405
1089
  if (!res || res.error) {
406
1090
  if (isAlive(state.pid)) process.kill(state.pid, "SIGTERM");
407
1091
  console.log(`Session "${state.name}" didn't answer; sent it SIGTERM.`);
408
1092
  return;
409
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
+ }
410
1108
  console.log(
411
1109
  `Stopped session "${state.name}". ${describeRestore(res.restore)}`
412
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}`);
413
1117
  }
414
1118
  function list() {
415
1119
  const sessions = listSessions();
@@ -469,12 +1173,19 @@ async function daemonMain(flags) {
469
1173
  }
470
1174
 
471
1175
  // src/cli.ts
472
- 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
473
1177
 
474
1178
  Usage
475
1179
  agent-bridge devices Apps connected to Metro
476
1180
  agent-bridge tools Tools the app exposes
477
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
478
1189
  agent-bridge run <flow.mjs|.ts> Run a flow: export default async ({ step, call }) => {}
479
1190
  export const scenario = 'signedIn' applies it first
480
1191
  and runs bridge.restore after, even on failure
@@ -484,14 +1195,20 @@ Usage
484
1195
  Sessions: one connection for all of an agent's calls
485
1196
  agent-bridge session start [--name n] [--idle 15m] Connect once in the background
486
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
487
1199
  agent-bridge session list | status [--name n]
488
- 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>.
489
1202
 
490
1203
  Options
491
1204
  --metro <host:port> Metro dev server (env AGENT_BRIDGE_METRO, default localhost:8081)
492
1205
  --device <text> Pick an app when several are connected
493
1206
  --transport <name> auto (default), expo or cdp
494
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
495
1212
  --strict run: exit non-zero if the app logged an error
496
1213
  --scenario <name> run: also apply this scenario (repeatable). Options as
497
1214
  JSON after "=": --scenario 'signedIn={"user":{"name":"Ada"}}'
@@ -499,17 +1216,17 @@ Options
499
1216
  --no-session Connect directly even if a session is running
500
1217
  `;
501
1218
  var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
502
- function parseCallArgs(raw) {
503
- if (raw === void 0) return [];
504
- let value;
505
- try {
506
- value = JSON.parse(raw);
507
- } catch {
508
- return [raw];
509
- }
510
- 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");
511
1223
  }
512
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
+ });
513
1230
  const { values, positionals } = parseArgs({
514
1231
  allowPositionals: true,
515
1232
  options: {
@@ -518,10 +1235,15 @@ async function main() {
518
1235
  transport: { type: "string" },
519
1236
  timeout: { type: "string" },
520
1237
  strict: { type: "boolean" },
1238
+ out: { type: "string" },
1239
+ full: { type: "boolean" },
1240
+ batch: { type: "boolean" },
1241
+ "stop-on-error": { type: "boolean" },
521
1242
  scenario: { type: "string", multiple: true },
522
1243
  name: { type: "string" },
523
1244
  idle: { type: "string" },
524
1245
  keep: { type: "boolean" },
1246
+ "dry-run": { type: "boolean" },
525
1247
  session: { type: "string" },
526
1248
  "no-session": { type: "boolean" },
527
1249
  help: { type: "boolean", short: "h" }
@@ -529,7 +1251,7 @@ async function main() {
529
1251
  });
530
1252
  const [command, ...rest] = positionals;
531
1253
  if (!command || values.help) {
532
- process.stdout.write(HELP);
1254
+ process.stdout.write(HELP2);
533
1255
  return;
534
1256
  }
535
1257
  const options = {
@@ -540,6 +1262,7 @@ async function main() {
540
1262
  };
541
1263
  const withBridge = async (fn) => {
542
1264
  const session = sessionFor(values);
1265
+ if (session) console.error(`Using session "${session.name}"`);
543
1266
  const bridge = session ? await connectSession({
544
1267
  name: session.name,
545
1268
  timeoutMs: options.timeoutMs
@@ -550,6 +1273,14 @@ async function main() {
550
1273
  bridge.close();
551
1274
  }
552
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
+ };
553
1284
  switch (command) {
554
1285
  case "devices": {
555
1286
  const devices = await listDevices(options);
@@ -567,20 +1298,50 @@ async function main() {
567
1298
  console.log(` ${t.name.padEnd(22)} ${t.description ?? ""}`);
568
1299
  });
569
1300
  case "call": {
570
- 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;
571
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
+ );
572
1315
  return withBridge(async (bridge) => {
573
- 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) => {
574
1317
  for (const e of failedLogs(error)) console.error(logLine(e));
575
1318
  throw error;
576
1319
  });
577
- console.log(JSON.stringify(value, null, 2));
1320
+ console.log(
1321
+ await renderResult(value, { out: values.out, full: values.full })
1322
+ );
578
1323
  for (const e of logs) console.error(logLine(e));
1324
+ if (notice) console.error(`Warning: ${notice}`);
579
1325
  console.error(
580
1326
  `${tool} via ${bridge.transport}: ${ms.toFixed(1)} ms round trip, ${appMs} ms in the app`
581
1327
  );
582
1328
  });
583
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
+ });
584
1345
  case "run": {
585
1346
  const [file] = rest;
586
1347
  if (!file) throw new Error("Usage: agent-bridge run <flow file>");
@@ -637,7 +1398,7 @@ async function main() {
637
1398
  default:
638
1399
  throw new Error(`Unknown command "${command}".
639
1400
 
640
- ${HELP}`);
1401
+ ${HELP2}`);
641
1402
  }
642
1403
  }
643
1404
  main().catch((error) => {