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