@avasapp/agent-bridge 0.1.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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/adapters/expo-router.cjs +144 -0
- package/dist/adapters/expo-router.d.cts +38 -0
- package/dist/adapters/react-native-mmkv.cjs +102 -0
- package/dist/adapters/react-native-mmkv.d.cts +15 -0
- package/dist/adapters/tanstack-query.cjs +161 -0
- package/dist/adapters/tanstack-query.d.cts +10 -0
- package/dist/adapters/zustand.cjs +97 -0
- package/dist/adapters/zustand.d.cts +15 -0
- package/dist/chunk-UEWFQWCY.js +575 -0
- package/dist/cli.js +657 -0
- package/dist/client/index.cjs +568 -0
- package/dist/client/index.d.ts +112 -0
- package/dist/client/index.js +14 -0
- package/dist/expo/index.cjs +62 -0
- package/dist/expo/index.d.cts +9 -0
- package/dist/network/index.cjs +568 -0
- package/dist/network/index.d.cts +85 -0
- package/dist/noop/expo-router.cjs +26 -0
- package/dist/noop/expo.cjs +27 -0
- package/dist/noop/index.cjs +36 -0
- package/dist/noop/network.cjs +34 -0
- package/dist/noop/react-native-mmkv.cjs +26 -0
- package/dist/noop/tanstack-query.cjs +26 -0
- package/dist/noop/zustand.cjs +26 -0
- package/dist/runtime/index.cjs +933 -0
- package/dist/runtime/index.d.cts +69 -0
- package/dist/types-C6DUUHnB.d.cts +76 -0
- package/entries/expo-router.cjs +9 -0
- package/entries/expo.cjs +9 -0
- package/entries/index.cjs +9 -0
- package/entries/network.cjs +9 -0
- package/entries/react-native-mmkv.cjs +9 -0
- package/entries/tanstack-query.cjs +9 -0
- package/entries/zustand.cjs +9 -0
- package/package.json +120 -0
- package/skills/agent-bridge/SKILL.md +89 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AgentBridgeCallError,
|
|
4
|
+
RUNTIME_MARKER,
|
|
5
|
+
checkName,
|
|
6
|
+
connect,
|
|
7
|
+
connectSession,
|
|
8
|
+
formatDuration,
|
|
9
|
+
isAlive,
|
|
10
|
+
listDevices,
|
|
11
|
+
listSessions,
|
|
12
|
+
metroHost,
|
|
13
|
+
openConnection,
|
|
14
|
+
parseDuration,
|
|
15
|
+
pickSession,
|
|
16
|
+
readSession,
|
|
17
|
+
removeSessionFiles,
|
|
18
|
+
sessionFiles,
|
|
19
|
+
sessionRequest,
|
|
20
|
+
stateDir,
|
|
21
|
+
writePrivate
|
|
22
|
+
} from "./chunk-UEWFQWCY.js";
|
|
23
|
+
|
|
24
|
+
// src/cli.ts
|
|
25
|
+
import { readFile } from "fs/promises";
|
|
26
|
+
import { resolve } from "path";
|
|
27
|
+
import { pathToFileURL } from "url";
|
|
28
|
+
import { parseArgs } from "util";
|
|
29
|
+
|
|
30
|
+
// src/client/log-lines.ts
|
|
31
|
+
function logLine(entry) {
|
|
32
|
+
const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
|
|
33
|
+
const first = entry.message.split("\n")[0] ?? "";
|
|
34
|
+
const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
|
|
35
|
+
return `! ${entry.level}${where}: ${text}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/client/session/cli.ts
|
|
39
|
+
import { spawn } from "child_process";
|
|
40
|
+
import { existsSync, readFileSync, rmSync as rmSync2 } from "fs";
|
|
41
|
+
|
|
42
|
+
// src/client/session/daemon.ts
|
|
43
|
+
import { appendFileSync, chmodSync, rmSync } from "fs";
|
|
44
|
+
import { createServer } from "net";
|
|
45
|
+
import { createInterface } from "readline";
|
|
46
|
+
var message = (error) => error instanceof Error ? error.message : String(error);
|
|
47
|
+
var lostConnection = (r) => !r.ok && (r.from === "" || /socket closed|closed the debugger|not open/i.test(r.error));
|
|
48
|
+
async function runSessionDaemon(options) {
|
|
49
|
+
const name = checkName(options.name);
|
|
50
|
+
const dir = stateDir();
|
|
51
|
+
const files = sessionFiles(name, dir);
|
|
52
|
+
const metro = metroHost(options.metro);
|
|
53
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
54
|
+
const log = (text) => appendFileSync(files.log, `${(/* @__PURE__ */ new Date()).toISOString()} ${text}
|
|
55
|
+
`, {
|
|
56
|
+
mode: 384
|
|
57
|
+
});
|
|
58
|
+
const running = readSession(name, dir);
|
|
59
|
+
if (running)
|
|
60
|
+
throw new Error(`Session "${name}" is already running (pid ${running.pid})`);
|
|
61
|
+
const reach = () => openConnection({
|
|
62
|
+
metro,
|
|
63
|
+
device: options.device,
|
|
64
|
+
transport: options.transport
|
|
65
|
+
});
|
|
66
|
+
let conn = await reach();
|
|
67
|
+
const owner = listSessions(dir).find(
|
|
68
|
+
(s) => s.metro === metro && s.device.deviceId === conn.device.deviceId
|
|
69
|
+
);
|
|
70
|
+
if (owner) {
|
|
71
|
+
conn.close();
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${conn.device.name} already belongs to session "${owner.name}". Use --session ${owner.name}, or stop it first.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const deviceOf = (c) => ({
|
|
77
|
+
name: c.device.name,
|
|
78
|
+
deviceId: c.device.deviceId,
|
|
79
|
+
platform: c.device.platform
|
|
80
|
+
});
|
|
81
|
+
const state = {
|
|
82
|
+
name,
|
|
83
|
+
pid: process.pid,
|
|
84
|
+
socket: files.socket,
|
|
85
|
+
metro,
|
|
86
|
+
device: deviceOf(conn),
|
|
87
|
+
deviceFilter: options.device,
|
|
88
|
+
transport: conn.transport,
|
|
89
|
+
startedAt: Date.now(),
|
|
90
|
+
lastCallAt: Date.now(),
|
|
91
|
+
idleMs: options.idleMs
|
|
92
|
+
};
|
|
93
|
+
const save = () => {
|
|
94
|
+
if (!finishing) writePrivate(files.state, JSON.stringify(state, null, 2));
|
|
95
|
+
};
|
|
96
|
+
let stale = false;
|
|
97
|
+
let reconnecting = null;
|
|
98
|
+
const reconnect = (reason) => {
|
|
99
|
+
reconnecting ??= (async () => {
|
|
100
|
+
log(`reconnecting: ${reason}`);
|
|
101
|
+
conn.close();
|
|
102
|
+
try {
|
|
103
|
+
conn = await reach();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
stale = true;
|
|
106
|
+
log(`reconnect failed: ${message(error)}`);
|
|
107
|
+
throw new Error(
|
|
108
|
+
`The app is gone (${reason}). Reconnecting failed: ${message(error)}`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
stale = false;
|
|
112
|
+
state.device = deviceOf(conn);
|
|
113
|
+
state.transport = conn.transport;
|
|
114
|
+
save();
|
|
115
|
+
log(`reconnected to ${conn.device.name} via ${conn.transport}`);
|
|
116
|
+
})().finally(() => {
|
|
117
|
+
reconnecting = null;
|
|
118
|
+
});
|
|
119
|
+
return reconnecting;
|
|
120
|
+
};
|
|
121
|
+
const ping = (ms = 1500) => conn.call("bridge.ping", [], Math.min(ms, 1500)).then(
|
|
122
|
+
(r) => r.ok,
|
|
123
|
+
() => false
|
|
124
|
+
);
|
|
125
|
+
const attempt = async (tool, args, ms) => {
|
|
126
|
+
try {
|
|
127
|
+
const result = await conn.call(tool, args, ms);
|
|
128
|
+
return lostConnection(result) ? { lost: result.error } : { result };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (await ping(ms)) throw error;
|
|
131
|
+
return { lost: message(error) };
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const callApp = async (tool, args, ms) => {
|
|
135
|
+
if (stale || reconnecting)
|
|
136
|
+
await (reconnecting ?? reconnect("app stopped answering"));
|
|
137
|
+
let t0 = performance.now();
|
|
138
|
+
const first = await attempt(tool, args, ms);
|
|
139
|
+
if (first.result)
|
|
140
|
+
return { result: first.result, ms: performance.now() - t0 };
|
|
141
|
+
await reconnect(first.lost);
|
|
142
|
+
t0 = performance.now();
|
|
143
|
+
const second = await attempt(tool, args, ms);
|
|
144
|
+
if (!second.result)
|
|
145
|
+
throw new Error(
|
|
146
|
+
`The app stopped answering after a reconnect: ${second.lost}`
|
|
147
|
+
);
|
|
148
|
+
return {
|
|
149
|
+
result: second.result,
|
|
150
|
+
ms: performance.now() - t0,
|
|
151
|
+
reconnected: true
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
let inflight = 0;
|
|
155
|
+
let idleTimer;
|
|
156
|
+
const armIdle = () => {
|
|
157
|
+
clearTimeout(idleTimer);
|
|
158
|
+
if (!options.idleMs) return;
|
|
159
|
+
idleTimer = setTimeout(() => {
|
|
160
|
+
if (inflight) armIdle();
|
|
161
|
+
else void teardown("idle", false).then(closeClients);
|
|
162
|
+
}, options.idleMs);
|
|
163
|
+
};
|
|
164
|
+
const touch = () => {
|
|
165
|
+
if (finishing) return;
|
|
166
|
+
state.lastCallAt = Date.now();
|
|
167
|
+
save();
|
|
168
|
+
armIdle();
|
|
169
|
+
};
|
|
170
|
+
const health = options.healthMs === 0 ? void 0 : setInterval(async () => {
|
|
171
|
+
if (inflight || reconnecting || finishing) return;
|
|
172
|
+
if (stale || !await ping())
|
|
173
|
+
await reconnect("no answer to bridge.ping").catch(() => {
|
|
174
|
+
});
|
|
175
|
+
}, options.healthMs ?? 5e3);
|
|
176
|
+
const timer = health;
|
|
177
|
+
timer?.unref?.();
|
|
178
|
+
let finishing = null;
|
|
179
|
+
let resolveDone = () => {
|
|
180
|
+
};
|
|
181
|
+
const done = new Promise((r) => {
|
|
182
|
+
resolveDone = r;
|
|
183
|
+
});
|
|
184
|
+
const teardown = (reason, keep) => {
|
|
185
|
+
finishing ??= (async () => {
|
|
186
|
+
clearTimeout(idleTimer);
|
|
187
|
+
clearInterval(health);
|
|
188
|
+
let restore = null;
|
|
189
|
+
if (!keep) {
|
|
190
|
+
restore = await callApp("bridge.restore", [], timeoutMs).then(
|
|
191
|
+
(r) => r.result,
|
|
192
|
+
(error) => ({
|
|
193
|
+
id: "restore",
|
|
194
|
+
from: "",
|
|
195
|
+
ok: false,
|
|
196
|
+
error: message(error),
|
|
197
|
+
ms: 0
|
|
198
|
+
})
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
conn.close();
|
|
202
|
+
server.close();
|
|
203
|
+
removeSessionFiles(name, dir);
|
|
204
|
+
const outcome = !restore ? "kept app state" : restore.ok ? `restore: ${JSON.stringify(restore.value)}` : `restore: ${restore.error}`;
|
|
205
|
+
log(`stopped (${reason}); ${outcome}`);
|
|
206
|
+
const deadline = Date.now() + 2e3;
|
|
207
|
+
const settle = () => {
|
|
208
|
+
if (!clients.size || Date.now() > deadline) resolveDone(reason);
|
|
209
|
+
else setTimeout(settle, 10);
|
|
210
|
+
};
|
|
211
|
+
settle();
|
|
212
|
+
return restore;
|
|
213
|
+
})();
|
|
214
|
+
return finishing;
|
|
215
|
+
};
|
|
216
|
+
const handle = async (req) => {
|
|
217
|
+
if (finishing && req.op !== "stop")
|
|
218
|
+
return { id: req.id, error: `Session "${name}" is stopping` };
|
|
219
|
+
switch (req.op) {
|
|
220
|
+
case "call": {
|
|
221
|
+
if (!req.tool) return { id: req.id, error: "call needs a tool" };
|
|
222
|
+
inflight++;
|
|
223
|
+
touch();
|
|
224
|
+
try {
|
|
225
|
+
return {
|
|
226
|
+
id: req.id,
|
|
227
|
+
...await callApp(
|
|
228
|
+
req.tool,
|
|
229
|
+
req.args ?? [],
|
|
230
|
+
req.timeoutMs ?? timeoutMs
|
|
231
|
+
)
|
|
232
|
+
};
|
|
233
|
+
} finally {
|
|
234
|
+
inflight--;
|
|
235
|
+
touch();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
case "tools":
|
|
239
|
+
touch();
|
|
240
|
+
if (stale || reconnecting)
|
|
241
|
+
await (reconnecting ?? reconnect("app stopped answering"));
|
|
242
|
+
return { id: req.id, device: conn.device, transport: conn.transport };
|
|
243
|
+
case "info":
|
|
244
|
+
return {
|
|
245
|
+
id: req.id,
|
|
246
|
+
state,
|
|
247
|
+
device: conn.device,
|
|
248
|
+
transport: conn.transport
|
|
249
|
+
};
|
|
250
|
+
case "stop":
|
|
251
|
+
return { id: req.id, restore: await teardown("stop", !!req.keep) };
|
|
252
|
+
default:
|
|
253
|
+
return { id: req.id, error: `Unknown op "${String(req.op)}"` };
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const clients = /* @__PURE__ */ new Set();
|
|
257
|
+
const closeClients = () => {
|
|
258
|
+
for (const socket of clients) socket.end();
|
|
259
|
+
};
|
|
260
|
+
const server = createServer((socket) => {
|
|
261
|
+
clients.add(socket);
|
|
262
|
+
socket.on("close", () => clients.delete(socket));
|
|
263
|
+
socket.on("error", () => {
|
|
264
|
+
});
|
|
265
|
+
createInterface({ input: socket }).on("line", async (line) => {
|
|
266
|
+
let req;
|
|
267
|
+
try {
|
|
268
|
+
req = JSON.parse(line);
|
|
269
|
+
} catch {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const res = await handle(req).catch((error) => ({
|
|
273
|
+
id: req.id,
|
|
274
|
+
error: message(error)
|
|
275
|
+
}));
|
|
276
|
+
if (!socket.writableEnded) socket.write(`${JSON.stringify(res)}
|
|
277
|
+
`);
|
|
278
|
+
if (req.op === "stop") closeClients();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
if (process.platform !== "win32") rmSync(files.socket, { force: true });
|
|
282
|
+
try {
|
|
283
|
+
await new Promise((resolve2, reject) => {
|
|
284
|
+
server.once("error", reject);
|
|
285
|
+
server.listen(files.socket, () => {
|
|
286
|
+
server.off("error", reject);
|
|
287
|
+
resolve2();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
conn.close();
|
|
292
|
+
clearInterval(health);
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
if (process.platform !== "win32") chmodSync(files.socket, 384);
|
|
296
|
+
touch();
|
|
297
|
+
log(
|
|
298
|
+
`started: ${conn.device.name} via ${conn.transport} on ${metro}, pid ${process.pid}, idle ${options.idleMs} ms`
|
|
299
|
+
);
|
|
300
|
+
return {
|
|
301
|
+
state,
|
|
302
|
+
stop: async (keep = false) => {
|
|
303
|
+
const restore = await teardown("stop", keep);
|
|
304
|
+
closeClients();
|
|
305
|
+
return restore;
|
|
306
|
+
},
|
|
307
|
+
done
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/client/session/cli.ts
|
|
312
|
+
var DAEMON_COMMAND = "__session-daemon";
|
|
313
|
+
function sessionFor(flags) {
|
|
314
|
+
if (flags["no-session"]) return null;
|
|
315
|
+
return pickSession({
|
|
316
|
+
name: flags.session ?? process.env.AGENT_BRIDGE_SESSION,
|
|
317
|
+
metro: metroHost(flags.metro),
|
|
318
|
+
device: flags.device,
|
|
319
|
+
transport: flags.transport
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
var describeRestore = (restore) => {
|
|
323
|
+
if (!restore) return "App state kept (--keep).";
|
|
324
|
+
if (restore.ok) return `bridge.restore: ${JSON.stringify(restore.value)}`;
|
|
325
|
+
if (/Unknown tool/.test(restore.error))
|
|
326
|
+
return "The app has no bridge.restore; nothing was undone.";
|
|
327
|
+
return `bridge.restore failed: ${restore.error}`;
|
|
328
|
+
};
|
|
329
|
+
function chosen(flags) {
|
|
330
|
+
const name = flags.name ?? flags.session ?? process.env.AGENT_BRIDGE_SESSION;
|
|
331
|
+
if (name) {
|
|
332
|
+
const state = readSession(checkName(name));
|
|
333
|
+
if (!state) throw new Error(`No session named "${name}" is running`);
|
|
334
|
+
return state;
|
|
335
|
+
}
|
|
336
|
+
const all = listSessions();
|
|
337
|
+
if (all.length === 1) return all[0];
|
|
338
|
+
const match = sessionFor({ ...flags, session: void 0 });
|
|
339
|
+
if (match) return match;
|
|
340
|
+
throw new Error(
|
|
341
|
+
all.length ? `Several sessions are running (${all.map((s) => s.name).join(", ")}); pass --name` : "No session is running"
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
function freeName() {
|
|
345
|
+
const taken = new Set(listSessions().map((s) => s.name));
|
|
346
|
+
let name = "default";
|
|
347
|
+
for (let n = 2; taken.has(name); n++) name = `default-${n}`;
|
|
348
|
+
return name;
|
|
349
|
+
}
|
|
350
|
+
async function start(flags) {
|
|
351
|
+
const name = checkName(flags.name ?? freeName());
|
|
352
|
+
const running = readSession(name);
|
|
353
|
+
if (running)
|
|
354
|
+
throw new Error(`Session "${name}" is already running (pid ${running.pid})`);
|
|
355
|
+
const files = sessionFiles(name);
|
|
356
|
+
rmSync2(files.error, { force: true });
|
|
357
|
+
const args = [
|
|
358
|
+
DAEMON_COMMAND,
|
|
359
|
+
"--name",
|
|
360
|
+
name,
|
|
361
|
+
"--metro",
|
|
362
|
+
metroHost(flags.metro)
|
|
363
|
+
];
|
|
364
|
+
args.push("--idle", String(parseDuration(flags.idle ?? "15m")));
|
|
365
|
+
if (flags.device) args.push("--device", flags.device);
|
|
366
|
+
if (flags.transport) args.push("--transport", flags.transport);
|
|
367
|
+
if (flags.timeout) args.push("--timeout", flags.timeout);
|
|
368
|
+
const child = spawn(
|
|
369
|
+
process.execPath,
|
|
370
|
+
[...process.execArgv, process.argv[1], ...args],
|
|
371
|
+
{ detached: true, stdio: "ignore", windowsHide: true }
|
|
372
|
+
);
|
|
373
|
+
const state = await new Promise((resolve2, reject) => {
|
|
374
|
+
const deadline = Date.now() + 3e4;
|
|
375
|
+
let exited = false;
|
|
376
|
+
child.once("exit", () => {
|
|
377
|
+
exited = true;
|
|
378
|
+
});
|
|
379
|
+
const tick = setInterval(() => {
|
|
380
|
+
const ready = readSession(name);
|
|
381
|
+
const failed = existsSync(files.error);
|
|
382
|
+
if (ready && ready.pid === child.pid) {
|
|
383
|
+
clearInterval(tick);
|
|
384
|
+
resolve2(ready);
|
|
385
|
+
} else if (failed || exited || Date.now() > deadline) {
|
|
386
|
+
clearInterval(tick);
|
|
387
|
+
if (!exited) child.kill();
|
|
388
|
+
const reason = failed ? readFileSync(files.error, "utf8") : exited ? `The session exited early; see ${files.log}` : `The session didn't connect within 30 s; see ${files.log}`;
|
|
389
|
+
rmSync2(files.error, { force: true });
|
|
390
|
+
reject(new Error(reason));
|
|
391
|
+
}
|
|
392
|
+
}, 50);
|
|
393
|
+
});
|
|
394
|
+
child.unref();
|
|
395
|
+
const idle = state.idleMs ? `, stops after ${formatDuration(state.idleMs)} idle` : "";
|
|
396
|
+
console.log(
|
|
397
|
+
`Session "${state.name}": ${state.device.name} via ${state.transport}${idle}`
|
|
398
|
+
);
|
|
399
|
+
console.log(
|
|
400
|
+
"`call`, `tools` and `run` go through it; `agent-bridge session stop` undoes and ends it."
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
async function stop(flags) {
|
|
404
|
+
const state = chosen(flags);
|
|
405
|
+
const res = await sessionRequest(state, {
|
|
406
|
+
op: "stop",
|
|
407
|
+
keep: !!flags.keep
|
|
408
|
+
}).catch(() => null);
|
|
409
|
+
if (!res || res.error) {
|
|
410
|
+
if (isAlive(state.pid)) process.kill(state.pid, "SIGTERM");
|
|
411
|
+
console.log(`Session "${state.name}" didn't answer; sent it SIGTERM.`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
console.log(
|
|
415
|
+
`Stopped session "${state.name}". ${describeRestore(res.restore)}`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
function list() {
|
|
419
|
+
const sessions = listSessions();
|
|
420
|
+
if (!sessions.length) console.log("No sessions.");
|
|
421
|
+
for (const s of sessions) {
|
|
422
|
+
const left = s.idleMs ? `${formatDuration(s.lastCallAt + s.idleMs - Date.now())} left` : "no idle limit";
|
|
423
|
+
console.log(
|
|
424
|
+
`${s.name.padEnd(12)} ${s.device.name} ${s.transport} ${s.metro} ${left} pid ${s.pid}`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function status(flags) {
|
|
429
|
+
const state = chosen(flags);
|
|
430
|
+
const res = await sessionRequest(state, { op: "info" });
|
|
431
|
+
if (res.error) throw new Error(res.error);
|
|
432
|
+
console.log(
|
|
433
|
+
JSON.stringify({ ...res.state, tools: res.device?.tools.length }, null, 2)
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
async function sessionCommand(sub, flags) {
|
|
437
|
+
switch (sub) {
|
|
438
|
+
case "start":
|
|
439
|
+
return start(flags);
|
|
440
|
+
case "stop":
|
|
441
|
+
return stop(flags);
|
|
442
|
+
case "list":
|
|
443
|
+
return list();
|
|
444
|
+
case "status":
|
|
445
|
+
return status(flags);
|
|
446
|
+
default:
|
|
447
|
+
throw new Error("Usage: agent-bridge session start|stop|list|status");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function daemonMain(flags) {
|
|
451
|
+
const name = checkName(flags.name ?? "default");
|
|
452
|
+
let daemon;
|
|
453
|
+
try {
|
|
454
|
+
daemon = await runSessionDaemon({
|
|
455
|
+
name,
|
|
456
|
+
metro: flags.metro,
|
|
457
|
+
device: flags.device,
|
|
458
|
+
transport: flags.transport,
|
|
459
|
+
idleMs: parseDuration(flags.idle ?? "15m"),
|
|
460
|
+
timeoutMs: flags.timeout ? Number(flags.timeout) : void 0
|
|
461
|
+
});
|
|
462
|
+
} catch (error) {
|
|
463
|
+
writePrivate(
|
|
464
|
+
sessionFiles(name).error,
|
|
465
|
+
error instanceof Error ? error.message : String(error)
|
|
466
|
+
);
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"])
|
|
470
|
+
process.on(signal, () => void daemon.stop());
|
|
471
|
+
await daemon.done;
|
|
472
|
+
process.exit(0);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/cli.ts
|
|
476
|
+
var HELP = `agent-bridge: drive a running React Native app from an agent
|
|
477
|
+
|
|
478
|
+
Usage
|
|
479
|
+
agent-bridge devices Apps connected to Metro
|
|
480
|
+
agent-bridge tools Tools the app exposes
|
|
481
|
+
agent-bridge call <tool> [args] Call a tool. args: a JSON array, or one JSON value
|
|
482
|
+
agent-bridge run <flow.mjs|.ts> Run a flow: export default async ({ step, call }) => {}
|
|
483
|
+
agent-bridge assert-absent <files...> Fail if a release bundle contains the bridge
|
|
484
|
+
|
|
485
|
+
Sessions: one connection for all of an agent's calls
|
|
486
|
+
agent-bridge session start [--name n] [--idle 15m] Connect once in the background
|
|
487
|
+
agent-bridge session stop [--name n] [--keep] bridge.restore (unless --keep), then end
|
|
488
|
+
agent-bridge session list | status [--name n]
|
|
489
|
+
While a session matches --metro/--device, call, tools and run use it.
|
|
490
|
+
|
|
491
|
+
Options
|
|
492
|
+
--metro <host:port> Metro dev server (env AGENT_BRIDGE_METRO, default localhost:8081)
|
|
493
|
+
--device <text> Pick an app when several are connected
|
|
494
|
+
--transport <name> auto (default), expo or cdp
|
|
495
|
+
--timeout <ms> Per-call timeout (default 10000)
|
|
496
|
+
--strict run: exit non-zero if the app logged an error
|
|
497
|
+
--session <name> Use this session (env AGENT_BRIDGE_SESSION)
|
|
498
|
+
--no-session Connect directly even if a session is running
|
|
499
|
+
`;
|
|
500
|
+
var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
|
|
501
|
+
function parseCallArgs(raw) {
|
|
502
|
+
if (raw === void 0) return [];
|
|
503
|
+
let value;
|
|
504
|
+
try {
|
|
505
|
+
value = JSON.parse(raw);
|
|
506
|
+
} catch {
|
|
507
|
+
return [raw];
|
|
508
|
+
}
|
|
509
|
+
return Array.isArray(value) ? value : [value];
|
|
510
|
+
}
|
|
511
|
+
async function main() {
|
|
512
|
+
const { values, positionals } = parseArgs({
|
|
513
|
+
allowPositionals: true,
|
|
514
|
+
options: {
|
|
515
|
+
metro: { type: "string" },
|
|
516
|
+
device: { type: "string" },
|
|
517
|
+
transport: { type: "string" },
|
|
518
|
+
timeout: { type: "string" },
|
|
519
|
+
strict: { type: "boolean" },
|
|
520
|
+
name: { type: "string" },
|
|
521
|
+
idle: { type: "string" },
|
|
522
|
+
keep: { type: "boolean" },
|
|
523
|
+
session: { type: "string" },
|
|
524
|
+
"no-session": { type: "boolean" },
|
|
525
|
+
help: { type: "boolean", short: "h" }
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
const [command, ...rest] = positionals;
|
|
529
|
+
if (!command || values.help) {
|
|
530
|
+
process.stdout.write(HELP);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const options = {
|
|
534
|
+
metro: values.metro,
|
|
535
|
+
device: values.device,
|
|
536
|
+
transport: values.transport,
|
|
537
|
+
timeoutMs: values.timeout ? Number(values.timeout) : void 0
|
|
538
|
+
};
|
|
539
|
+
const withBridge = async (fn) => {
|
|
540
|
+
const session = sessionFor(values);
|
|
541
|
+
const bridge = session ? await connectSession({
|
|
542
|
+
name: session.name,
|
|
543
|
+
timeoutMs: options.timeoutMs
|
|
544
|
+
}) : await connect(options);
|
|
545
|
+
try {
|
|
546
|
+
await fn(bridge);
|
|
547
|
+
} finally {
|
|
548
|
+
bridge.close();
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
switch (command) {
|
|
552
|
+
case "devices": {
|
|
553
|
+
const devices = await listDevices(options);
|
|
554
|
+
if (!devices.length) console.log("No apps connected.");
|
|
555
|
+
for (const d of devices) {
|
|
556
|
+
const extra = d.tools !== void 0 ? ` ${d.tools} tools ${d.deviceId}` : "";
|
|
557
|
+
console.log(`${d.transport.padEnd(5)} ${d.name}${extra}`);
|
|
558
|
+
}
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
case "tools":
|
|
562
|
+
return withBridge(async (bridge) => {
|
|
563
|
+
console.log(`${bridge.device.name} via ${bridge.transport}`);
|
|
564
|
+
for (const t of bridge.tools())
|
|
565
|
+
console.log(` ${t.name.padEnd(22)} ${t.description ?? ""}`);
|
|
566
|
+
});
|
|
567
|
+
case "call": {
|
|
568
|
+
const [tool, raw] = rest;
|
|
569
|
+
if (!tool) throw new Error("Usage: agent-bridge call <tool> [args]");
|
|
570
|
+
return withBridge(async (bridge) => {
|
|
571
|
+
const { value, ms, appMs, logs } = await bridge.timed(tool, ...parseCallArgs(raw)).catch((error) => {
|
|
572
|
+
for (const e of failedLogs(error)) console.error(logLine(e));
|
|
573
|
+
throw error;
|
|
574
|
+
});
|
|
575
|
+
console.log(JSON.stringify(value, null, 2));
|
|
576
|
+
for (const e of logs) console.error(logLine(e));
|
|
577
|
+
console.error(
|
|
578
|
+
`${tool} via ${bridge.transport}: ${ms.toFixed(1)} ms round trip, ${appMs} ms in the app`
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
case "run": {
|
|
583
|
+
const [file] = rest;
|
|
584
|
+
if (!file) throw new Error("Usage: agent-bridge run <flow file>");
|
|
585
|
+
const flow = await import(pathToFileURL(resolve(file)).href);
|
|
586
|
+
return withBridge(async (bridge) => {
|
|
587
|
+
let n = 0;
|
|
588
|
+
let total = 0;
|
|
589
|
+
let errors = 0;
|
|
590
|
+
const t0 = performance.now();
|
|
591
|
+
const report = (logs) => {
|
|
592
|
+
errors += logs.length;
|
|
593
|
+
for (const e of logs) console.log(` ${logLine(e)}`);
|
|
594
|
+
};
|
|
595
|
+
const timed = async (tool, ...args) => {
|
|
596
|
+
try {
|
|
597
|
+
const result = await bridge.timed(tool, ...args);
|
|
598
|
+
report(result.logs);
|
|
599
|
+
return result;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
report(failedLogs(error));
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
const call = async (tool, ...args) => (await timed(tool, ...args)).value;
|
|
606
|
+
const step = async (label, tool, ...args) => {
|
|
607
|
+
const { value, ms, logs } = await bridge.timed(tool, ...args).catch(
|
|
608
|
+
(error) => {
|
|
609
|
+
report(failedLogs(error));
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
);
|
|
613
|
+
total += ms;
|
|
614
|
+
console.log(
|
|
615
|
+
`${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
|
|
616
|
+
);
|
|
617
|
+
report(logs);
|
|
618
|
+
return value;
|
|
619
|
+
};
|
|
620
|
+
await flow.default({ bridge: { ...bridge, timed, call }, call, step });
|
|
621
|
+
console.log(
|
|
622
|
+
`${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
|
|
623
|
+
);
|
|
624
|
+
if (values.strict && errors) process.exitCode = 1;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
case "session":
|
|
628
|
+
return sessionCommand(rest[0], values);
|
|
629
|
+
case DAEMON_COMMAND:
|
|
630
|
+
return daemonMain(values);
|
|
631
|
+
case "assert-absent": {
|
|
632
|
+
if (!rest.length)
|
|
633
|
+
throw new Error("Usage: agent-bridge assert-absent <bundle files...>");
|
|
634
|
+
const found = [];
|
|
635
|
+
for (const file of rest) {
|
|
636
|
+
if ((await readFile(file)).includes(RUNTIME_MARKER)) found.push(file);
|
|
637
|
+
}
|
|
638
|
+
if (found.length) {
|
|
639
|
+
console.error(
|
|
640
|
+
`agent-bridge is in a release bundle: ${found.join(", ")}`
|
|
641
|
+
);
|
|
642
|
+
process.exitCode = 1;
|
|
643
|
+
} else {
|
|
644
|
+
console.log(`agent-bridge is absent from ${rest.length} file(s).`);
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
default:
|
|
649
|
+
throw new Error(`Unknown command "${command}".
|
|
650
|
+
|
|
651
|
+
${HELP}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
main().catch((error) => {
|
|
655
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
656
|
+
process.exitCode = 1;
|
|
657
|
+
});
|