@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/README.md +24 -225
- package/dist/adapters/ably.cjs +22 -4
- package/dist/adapters/ably.d.cts +8 -1
- package/dist/adapters/expo-router.cjs +22 -1
- package/dist/adapters/expo-router.d.cts +5 -1
- package/dist/adapters/react-native-mmkv.cjs +1 -0
- package/dist/adapters/react-native-mmkv.d.cts +1 -1
- package/dist/adapters/react-native-webview.cjs +672 -0
- package/dist/adapters/react-native-webview.d.cts +79 -0
- package/dist/adapters/socket-io.cjs +3 -1
- package/dist/adapters/socket-io.d.cts +1 -1
- package/dist/adapters/tanstack-query.cjs +256 -17
- package/dist/adapters/tanstack-query.d.cts +1 -1
- package/dist/adapters/zustand.cjs +152 -12
- package/dist/adapters/zustand.d.cts +19 -3
- package/dist/{chunk-S2FYN2R5.js → chunk-AJD3XTNT.js} +223 -57
- package/dist/cli.js +817 -56
- package/dist/client/index.cjs +218 -56
- package/dist/client/index.d.ts +20 -2
- package/dist/client/index.js +1 -1
- package/dist/expo/index.d.cts +1 -1
- package/dist/network/index.cjs +254 -51
- package/dist/network/index.d.cts +9 -2
- package/dist/noop/index.cjs +3 -0
- package/dist/noop/react-native-webview.cjs +38 -0
- package/dist/realtime/index.cjs +2 -1
- package/dist/realtime/index.d.cts +3 -1
- package/dist/runtime/index.cjs +860 -100
- package/dist/runtime/index.d.cts +31 -5
- package/dist/{types-6kKQsLC1.d.cts → types-BmpykP1P.d.cts} +23 -1
- package/entries/react-native-webview.cjs +9 -0
- package/package.json +17 -6
- package/skills/agent-bridge/SKILL.md +196 -5
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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(
|
|
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
|
-
|
|
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, ...
|
|
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(
|
|
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
|
-
${
|
|
1401
|
+
${HELP2}`);
|
|
641
1402
|
}
|
|
642
1403
|
}
|
|
643
1404
|
main().catch((error) => {
|