@infomity/remogent 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { networkInterfaces } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { Bonjour } from "bonjour-service";
8
+ import qrcode from "qrcode-terminal";
9
+ import { DEFAULT_PORT, loadOrCreateIdentity, savedPort } from "./identity.js";
10
+ import { isPortFree, pickFreePort } from "./port.js";
11
+ import { createClaudeAdapter, findClaude } from "./adapters/claude.js";
12
+ import { createCodexAdapter, findCodex } from "./adapters/codex.js";
13
+ import { createGeminiAdapter, findGemini } from "./adapters/gemini.js";
14
+ import { killTree, spawnCli } from "./adapters/util.js";
15
+ import { SessionManager } from "./sessions.js";
16
+ import { SESSIONS_FILE, SessionStore } from "./store.js";
17
+ import { logToFile } from "./logfile.js";
18
+ import { LOG_FILE, installService, probeHealth, serviceStatus, stopService, uninstallService, waitHealthy } from "./service.js";
19
+ import { ELEVATE_HINT, elevateFirewallRule, ensureFirewallRule, firewallState, removeFirewallRule } from "./firewall.js";
20
+ import { startServer } from "./server.js";
21
+ const VIRTUAL_IFACE = /vethernet|vmware|virtualbox|vbox|docker|wsl|hyper-v|veth|br-|virbr|utun|tailscale|zerotier|loopback/i;
22
+ function ipv4Rank(address, iface) {
23
+ const [a, b] = address.split(".").map(Number);
24
+ const rfc1918 = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
25
+ const cgnat = a === 100 && b >= 64 && b <= 127; // Tailscale / carrier-grade NAT
26
+ if (a === 169 && b === 254)
27
+ return -1; // link-local: never advertise
28
+ if (rfc1918 && !VIRTUAL_IFACE.test(iface))
29
+ return 0; // real private LAN
30
+ if (rfc1918)
31
+ return 1; // private but virtual (WSL, Hyper-V, Docker)
32
+ if (cgnat)
33
+ return 2; // Tailscale
34
+ return 3;
35
+ }
36
+ export function lanAddress() {
37
+ let best;
38
+ for (const [iface, addrs] of Object.entries(networkInterfaces())) {
39
+ for (const a of addrs ?? []) {
40
+ if (a.family !== "IPv4" || a.internal)
41
+ continue;
42
+ const rank = ipv4Rank(a.address, iface);
43
+ if (rank < 0)
44
+ continue;
45
+ if (!best || rank < best.rank)
46
+ best = { address: a.address, rank };
47
+ }
48
+ }
49
+ return best?.address ?? "127.0.0.1";
50
+ }
51
+ /** Other addresses the phone can try if the primary one is unreachable (e.g. Wi-Fi changed, or away from home over
52
+ * Tailscale): real private LAN addresses and Tailscale. Virtual adapters (WSL, Docker, Hyper-V) are left out. */
53
+ export function alternateAddresses(primary) {
54
+ const out = [];
55
+ for (const [iface, addrs] of Object.entries(networkInterfaces())) {
56
+ for (const a of addrs ?? []) {
57
+ if (a.family !== "IPv4" || a.internal || a.address === primary || out.includes(a.address))
58
+ continue;
59
+ const rank = ipv4Rank(a.address, iface);
60
+ if (rank === 0 || rank === 2)
61
+ out.push(a.address);
62
+ }
63
+ }
64
+ return out.slice(0, 4);
65
+ }
66
+ /** Explicit `--host IP` means exactly that address; otherwise the QR also carries the other usable addresses. */
67
+ function altsFor(flags, host) {
68
+ return typeof flags.get("host") === "string" ? [] : alternateAddresses(host);
69
+ }
70
+ export function pairingUrl(id, host, alt = []) {
71
+ const q = new URLSearchParams({
72
+ host,
73
+ port: String(id.port),
74
+ token: id.token,
75
+ fp: id.fingerprint,
76
+ name: id.name,
77
+ });
78
+ if (alt.length > 0)
79
+ q.set("alt", alt.join(","));
80
+ return `remogent://pair?${q.toString()}`;
81
+ }
82
+ function parseArgs(argv) {
83
+ const flags = new Map();
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const a = argv[i];
86
+ if (!a.startsWith("--"))
87
+ continue;
88
+ const next = argv[i + 1];
89
+ if (next && !next.startsWith("--")) {
90
+ flags.set(a.slice(2), next);
91
+ i++;
92
+ }
93
+ else
94
+ flags.set(a.slice(2), true);
95
+ }
96
+ return flags;
97
+ }
98
+ async function start(flags) {
99
+ // Under the autostart service there is no console: send output to the log file (before anything else prints).
100
+ const logFile = flags.get("log");
101
+ if (typeof logFile === "string")
102
+ logToFile(logFile);
103
+ const port = portOf(flags);
104
+ const identity = loadOrCreateIdentity({ port, rotate: flags.has("rotate") });
105
+ const host = typeof flags.get("host") === "string" ? flags.get("host") : lanAddress();
106
+ const persist = !flags.has("no-persist");
107
+ const sessions = new SessionManager(persist ? new SessionStore(SESSIONS_FILE, console.log) : undefined);
108
+ const geminiBin = findGemini();
109
+ if (geminiBin)
110
+ sessions.registerAdapter(createGeminiAdapter(geminiBin));
111
+ const claudeBin = findClaude();
112
+ if (claudeBin)
113
+ sessions.registerAdapter(createClaudeAdapter(claudeBin));
114
+ const codexBin = findCodex();
115
+ if (codexBin)
116
+ sessions.registerAdapter(createCodexAdapter(codexBin));
117
+ console.log(`Engines: ${sessions.engines().join(", ") || "none found (install gemini, claude or codex CLI)"}`);
118
+ if (persist) {
119
+ const restored = await sessions.restore();
120
+ console.log(`Sessions: ${restored} restored from ${SESSIONS_FILE} (--no-persist disables saving)`);
121
+ }
122
+ else {
123
+ console.log("Sessions: persistence disabled (--no-persist); chats are lost when the daemon stops");
124
+ }
125
+ const server = await startServer(identity, console.log, { sessions });
126
+ const bonjour = new Bonjour();
127
+ const svc = bonjour.publish({
128
+ name: `remogent-${identity.name}`,
129
+ type: "remogent",
130
+ protocol: "tcp",
131
+ port: identity.port,
132
+ txt: { fp: identity.fingerprint.slice(0, 16), v: "1" },
133
+ });
134
+ const url = pairingUrl(identity, host, altsFor(flags, host));
135
+ console.log(`\nRemogent host "${identity.name}" listening on wss://${host}:${identity.port}`);
136
+ console.log(`TLS fingerprint (SHA-256): ${identity.fingerprint}\n`);
137
+ if (!flags.has("no-qr"))
138
+ qrcode.generate(url, { small: true });
139
+ if (flags.has("show-url"))
140
+ console.log(`\n${url}`);
141
+ console.log("\nScan the QR code with the Remogent Android app. Ctrl+C to stop.");
142
+ const shutdown = async () => {
143
+ svc.stop?.();
144
+ bonjour.destroy();
145
+ await server.close();
146
+ process.exit(0);
147
+ };
148
+ process.on("SIGINT", shutdown);
149
+ process.on("SIGTERM", shutdown);
150
+ }
151
+ function portOf(flags) {
152
+ // --port wins, then the port stored with the identity (what the installed host and paired phones use), then the default.
153
+ return typeof flags.get("port") === "string" ? Number(flags.get("port")) : savedPort() ?? DEFAULT_PORT;
154
+ }
155
+ /** One-time PC setup: register autostart, start now, and show the pairing QR. Safe to re-run (it replaces the registration). */
156
+ async function install(flags) {
157
+ const explicit = typeof flags.get("port") === "string";
158
+ const previousPort = savedPort();
159
+ let port = portOf(flags);
160
+ stopService(); // an earlier registration, so the port is free for the new one
161
+ const squatter = await probeHealth(port);
162
+ if (squatter) {
163
+ console.log(`Warning: something already answers on port ${port} (a daemon started by hand?). Stop it (Ctrl+C) so the autostart service can take over.`);
164
+ }
165
+ else if (!(await isPortFree(port))) {
166
+ if (explicit) {
167
+ console.error(`Port ${port} is in use by another program. Pick another with --port N, or stop that program.`);
168
+ process.exit(1);
169
+ }
170
+ const next = await pickFreePort(port + 1);
171
+ if (next === undefined) {
172
+ console.error(`Port ${port} is in use and no free port was found above it. Pass --port N.`);
173
+ process.exit(1);
174
+ }
175
+ console.log(`Port ${port} is in use by another program; using ${next} instead.`);
176
+ port = next;
177
+ }
178
+ // Create/load the identity here, once, so the service and this command agree on the same token, certificate and port.
179
+ const identity = loadOrCreateIdentity({ port, rotate: flags.has("rotate"), persistPort: true });
180
+ const host = typeof flags.get("host") === "string" ? flags.get("host") : lanAddress();
181
+ if (previousPort !== undefined && previousPort !== identity.port && !flags.has("rotate")) {
182
+ console.log(`The host port changed (${previousPort} -> ${identity.port}), so phones paired before must scan the QR below once more.`);
183
+ }
184
+ const r = installService(port);
185
+ console.log(r.message);
186
+ if (!r.ok)
187
+ process.exit(1);
188
+ console.log(ensureFirewallRule(port).message); // never fatal: needs admin, and USB / an open firewall still work
189
+ if (!squatter) {
190
+ console.log(`Waiting for the host to come up (log: ${LOG_FILE}) ...`);
191
+ if (!(await waitHealthy(port, 20_000)))
192
+ console.log("It is not answering yet. Check the log above; `remogent status` shows the service state.");
193
+ }
194
+ console.log(`\nRemogent host "${identity.name}" on wss://${host}:${identity.port} will now start whenever you sign in.`);
195
+ console.log(`TLS fingerprint (SHA-256): ${identity.fingerprint}\n`);
196
+ const url = pairingUrl(identity, host, altsFor(flags, host));
197
+ if (!flags.has("no-qr"))
198
+ qrcode.generate(url, { small: true });
199
+ if (flags.has("show-url"))
200
+ console.log(`\n${url}`);
201
+ console.log("\nScan the QR code with the Remogent Android app (skip this if the phone is already paired).");
202
+ }
203
+ async function status(flags) {
204
+ const port = portOf(flags);
205
+ const s = serviceStatus();
206
+ console.log(`Autostart: ${s.detail}`);
207
+ const h = await probeHealth(port);
208
+ console.log(`Host on port ${port}: ${h ? `answering (${h.name ?? "unnamed"})` : "not answering"}`);
209
+ console.log(`Log: ${LOG_FILE}`);
210
+ process.exit(s.registered && h ? 0 : 1);
211
+ }
212
+ /** Shows the pairing QR again for the already-installed host (the service runs hidden, so nothing else ever prints it).
213
+ * Waits for the phone to connect, then exits so a window opened just for the QR closes itself (--no-wait keeps it open). */
214
+ async function pair(flags) {
215
+ const port = portOf(flags);
216
+ const identity = loadOrCreateIdentity({ port, rotate: false });
217
+ const host = typeof flags.get("host") === "string" ? flags.get("host") : lanAddress();
218
+ const url = pairingUrl(identity, host, altsFor(flags, host));
219
+ const baseline = (await probeHealth(port))?.connections; // undefined: host not running or an older host without the counter
220
+ console.log(`Remogent host "${identity.name}" at ${host}:${identity.port}\n`);
221
+ console.log('Open the Remogent app and tap "Scan QR to pair", then scan this code:\n');
222
+ qrcode.generate(url, { small: true });
223
+ if (flags.has("show-url"))
224
+ console.log(`\n${url}`);
225
+ if (flags.has("no-wait") || baseline === undefined) {
226
+ console.log("\nThe code contains your pairing secret: close this window when you are done.");
227
+ return;
228
+ }
229
+ const timeoutMin = typeof flags.get("timeout") === "string" ? Number(flags.get("timeout")) : 5;
230
+ console.log(`\nWaiting for the phone to connect (this window closes by itself; gives up after ${timeoutMin} min). Ctrl+C to close now.`);
231
+ const deadline = Date.now() + timeoutMin * 60_000;
232
+ while (Date.now() < deadline) {
233
+ await new Promise((r) => setTimeout(r, 1000));
234
+ const now = (await probeHealth(port))?.connections;
235
+ if (now !== undefined && now > baseline) {
236
+ console.log("\nPhone connected. Paired! Closing ...");
237
+ await new Promise((r) => setTimeout(r, 1500));
238
+ process.exit(0);
239
+ }
240
+ }
241
+ console.log("\nTimed out waiting for the phone; closing so the pairing secret is not left on screen. Run `npm run pair` to show it again.");
242
+ await new Promise((r) => setTimeout(r, 3000));
243
+ process.exit(1);
244
+ }
245
+ /** `--version` of an engine CLI (best effort, 8 s cap), so `doctor` proves the resolved path actually runs. */
246
+ function engineVersion(file) {
247
+ return new Promise((resolve) => {
248
+ let out = "";
249
+ let done = false;
250
+ const finish = (v) => { if (!done) {
251
+ done = true;
252
+ clearTimeout(timer);
253
+ resolve(v);
254
+ } };
255
+ const child = spawnCli(file, ["--version"], process.cwd());
256
+ const timer = setTimeout(() => { killTree(child); finish(undefined); }, 8000);
257
+ child.stdout.on("data", (d) => { out += d.toString(); });
258
+ child.on("error", () => finish(undefined));
259
+ child.on("close", (code) => finish(code === 0 ? out.trim().split(/\r?\n/)[0] || undefined : undefined));
260
+ });
261
+ }
262
+ /** Checks everything first-run setup depends on and says how to fix what is missing. Exit code 1 only for blockers. */
263
+ async function doctor(flags) {
264
+ const port = portOf(flags);
265
+ let blockers = 0;
266
+ const ok = (m) => console.log(` ok ${m}`);
267
+ const warn = (m) => console.log(` warn ${m}`);
268
+ const bad = (m) => { blockers++; console.log(` FAIL ${m}`); };
269
+ console.log("Remogent doctor\n");
270
+ const major = Number(process.versions.node.split(".")[0]);
271
+ if (major >= 20)
272
+ ok(`Node.js ${process.versions.node} (${process.execPath})`);
273
+ else
274
+ bad(`Node.js ${process.versions.node} is too old; install Node.js 20 or newer from https://nodejs.org`);
275
+ const claude = findClaude();
276
+ const gemini = findGemini();
277
+ const codex = findCodex();
278
+ for (const [name, file, install] of [
279
+ ["Claude Code", claude, "install it from https://claude.com/product/claude-code, then run `claude` once to sign in"],
280
+ ["Gemini CLI", gemini, "install it with `npm i -g @google/gemini-cli`, then run `gemini` once to sign in"],
281
+ ["Codex CLI", codex, "install it with `npm i -g @openai/codex`, then run `codex login` once to sign in"],
282
+ ]) {
283
+ if (!file) {
284
+ warn(`${name} not found (${install})`);
285
+ continue;
286
+ }
287
+ const v = await engineVersion(file);
288
+ if (v)
289
+ ok(`${name} ${v} (${file})`);
290
+ else
291
+ warn(`${name} found at ${file} but \`--version\` did not answer; run it in a terminal once to sign in or repair it`);
292
+ }
293
+ if (!claude && !gemini && !codex)
294
+ bad("no engine found: the app has nothing to drive. Install Claude Code, Gemini CLI or Codex CLI (see above), then run `doctor` again");
295
+ const addrs = [];
296
+ for (const [iface, list] of Object.entries(networkInterfaces())) {
297
+ for (const a of list ?? []) {
298
+ if (a.family === "IPv4" && !a.internal && ipv4Rank(a.address, iface) >= 0)
299
+ addrs.push({ address: a.address, iface, rank: ipv4Rank(a.address, iface) });
300
+ }
301
+ }
302
+ addrs.sort((x, y) => x.rank - y.rank);
303
+ if (addrs.length === 0)
304
+ warn("no network address found; the phone can only connect over USB (`adb reverse`) until this PC is on Wi-Fi or Ethernet");
305
+ else
306
+ ok(`address the QR will use: ${lanAddress()} (candidates: ${addrs.map((a) => `${a.address} on ${a.iface}`).join(", ")}); override with --host IP`);
307
+ const s = serviceStatus();
308
+ if (s.registered)
309
+ ok(`autostart: ${s.detail}`);
310
+ else
311
+ warn(`autostart not set up (${s.detail}); run \`node dist/index.js install\` so the host is always there`);
312
+ const h = await probeHealth(port);
313
+ if (h)
314
+ ok(`host answering on port ${port} (${h.name ?? "unnamed"})`);
315
+ else
316
+ warn(`nothing answering on port ${port} yet`);
317
+ const fw = firewallState(port);
318
+ if (fw.state === "open")
319
+ ok(`firewall: ${fw.detail}`);
320
+ else if (fw.state === "missing" || fw.state === "stale")
321
+ warn(`firewall: ${fw.detail}; Windows may block the phone on Wi-Fi. Fix: \`${ELEVATE_HINT}\``);
322
+ else if (fw.state === "unknown")
323
+ warn(`firewall: ${fw.detail}`);
324
+ console.log(blockers ? `\n${blockers} problem(s) to fix above.` : "\nAll set.");
325
+ process.exit(blockers ? 1 : 0);
326
+ }
327
+ /** True when this copy runs from a throw-away package cache (`npx`, `pnpm dlx`, `yarn dlx`): an autostart entry that points
328
+ * here would break as soon as the cache is cleaned. */
329
+ function isTransientCopy() {
330
+ return /[\\/](_npx|dlx-[^\\/]*|\.pnpm-dlx|dlx)[\\/]/i.test(fileURLToPath(import.meta.url));
331
+ }
332
+ /** One command for everything: check this PC (`doctor`), then register autostart + show the QR (`install`).
333
+ * From an `npx` cache it first installs the package globally and runs from that stable copy. */
334
+ function setup(args) {
335
+ let entry = fileURLToPath(import.meta.url);
336
+ if (isTransientCopy()) {
337
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
338
+ console.log(`Running from a temporary npx copy: installing ${pkg.name}@${pkg.version} globally first, so the autostart service points at a stable path ...\n`);
339
+ const r = spawnSync(`npm install -g ${pkg.name}@${pkg.version}`, { stdio: "inherit", shell: true });
340
+ if (r.status !== 0) {
341
+ console.error(`\nThe global install failed (permissions?). Run \`npm install -g ${pkg.name}\` yourself, then \`remogent setup\`.`);
342
+ process.exit(1);
343
+ }
344
+ const root = String(spawnSync("npm root -g", { shell: true, encoding: "utf8" }).stdout ?? "").trim();
345
+ const stable = join(root, ...pkg.name.split("/"), "dist", "index.js");
346
+ if (!root || !existsSync(stable)) {
347
+ console.error(`\nInstalled, but ${stable || "the global package folder"} was not found. Run \`remogent setup\` from a new terminal.`);
348
+ process.exit(1);
349
+ }
350
+ entry = stable;
351
+ }
352
+ const run = (cmdName) => spawnSync(process.execPath, [entry, cmdName, ...args], { stdio: "inherit" }).status ?? 1;
353
+ console.log("Step 1/2: checking this PC\n");
354
+ if (run("doctor") !== 0) {
355
+ console.error("\nFix the problem(s) above, then run `remogent setup` again.");
356
+ process.exit(1);
357
+ }
358
+ console.log("\nStep 2/2: starting the host and showing the pairing QR\n");
359
+ process.exit(run("install"));
360
+ }
361
+ const [cmd = "start", ...rest] = process.argv.slice(2);
362
+ const flags = parseArgs(rest);
363
+ if (cmd === "start") {
364
+ start(flags).catch((err) => {
365
+ if (err.code === "EADDRINUSE") {
366
+ console.error(`Port ${portOf(flags)} is already in use: is another Remogent host running (check \`remogent status\`)?`);
367
+ }
368
+ else {
369
+ console.error(err);
370
+ }
371
+ process.exit(1);
372
+ });
373
+ }
374
+ else if (cmd === "install") {
375
+ install(flags).catch((err) => { console.error(err); process.exit(1); });
376
+ }
377
+ else if (cmd === "uninstall") {
378
+ const r = uninstallService();
379
+ console.log(r.message);
380
+ const fw = removeFirewallRule();
381
+ if (fw.message)
382
+ console.log(fw.message);
383
+ process.exit(r.ok ? 0 : 1);
384
+ }
385
+ else if (cmd === "firewall") {
386
+ const port = portOf(flags);
387
+ const r = flags.has("elevate") ? elevateFirewallRule(port) : ensureFirewallRule(port);
388
+ console.log(r.message);
389
+ process.exit(r.ok ? 0 : 1);
390
+ }
391
+ else if (cmd === "status") {
392
+ status(flags).catch((err) => { console.error(err); process.exit(1); });
393
+ }
394
+ else if (cmd === "doctor") {
395
+ doctor(flags).catch((err) => { console.error(err); process.exit(1); });
396
+ }
397
+ else if (cmd === "setup") {
398
+ setup(rest);
399
+ }
400
+ else if (cmd === "pair") {
401
+ pair(flags).catch((err) => { console.error(err); process.exit(1); });
402
+ }
403
+ else {
404
+ console.log("Usage: remogent <command> [options]\n" +
405
+ " setup First-time setup in one step: doctor, then install (from npx it installs itself globally first)\n" +
406
+ " install Start the host at every sign-in (restarts on crash), start it now and show the pairing QR\n" +
407
+ " uninstall Stop and remove the autostart registration (and the Windows firewall rule, if allowed)\n" +
408
+ " firewall Windows: allow the phone through Defender Firewall on private networks (add --elevate to get the permission prompt)\n" +
409
+ " status Show autostart state and whether the host answers\n" +
410
+ " pair Show the pairing QR again (for a new phone), close itself once the phone connects; the installed host keeps running\n" +
411
+ " doctor Check Node, the Claude/Gemini/Codex CLIs (found automatically), network address and autostart, and say how to fix gaps\n" +
412
+ " start Run the host in this terminal (no autostart)\n" +
413
+ "Options: [--port N] [--host IP] [--rotate] [--no-qr] [--show-url] [--no-persist] [--log FILE] [--no-wait] [--timeout MIN]");
414
+ process.exit(cmd === "help" || cmd === "--help" ? 0 : 1);
415
+ }
416
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,39 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { format } from "node:util";
4
+ const ROTATE_AT_BYTES = 2 * 1024 * 1024;
5
+ /**
6
+ * Sends console output to a file (used when the daemon runs without a console, e.g. started by the autostart service).
7
+ * The previous log is kept as `<file>.old` once it exceeds 2 MiB (checked at startup only; the daemon logs very little).
8
+ * Fatal errors are written to the log too, then the process exits non-zero so a supervisor restarts it.
9
+ * Never log secrets here: the pairing token is only printed by `--show-url` / the QR, which the service does not use.
10
+ */
11
+ export function logToFile(file) {
12
+ try {
13
+ mkdirSync(dirname(file), { recursive: true });
14
+ if (statSync(file).size > ROTATE_AT_BYTES)
15
+ renameSync(file, `${file}.old`);
16
+ }
17
+ catch {
18
+ /* no log yet, or not rotatable: just append */
19
+ }
20
+ const write = (level, args) => {
21
+ try {
22
+ appendFileSync(file, `${new Date().toISOString()} ${level} ${format(...args)}\n`);
23
+ }
24
+ catch {
25
+ /* logging must never take the daemon down */
26
+ }
27
+ };
28
+ console.log = (...a) => write("INFO", a);
29
+ console.info = (...a) => write("INFO", a);
30
+ console.warn = (...a) => write("WARN", a);
31
+ console.error = (...a) => write("ERROR", a);
32
+ const fatal = (e) => {
33
+ write("FATAL", [e instanceof Error ? (e.stack ?? e.message) : String(e)]);
34
+ process.exit(1);
35
+ };
36
+ process.on("uncaughtException", fatal);
37
+ process.on("unhandledRejection", fatal);
38
+ }
39
+ //# sourceMappingURL=logfile.js.map
package/dist/port.js ADDED
@@ -0,0 +1,19 @@
1
+ import { createServer } from "node:net";
2
+ /** True when nothing is listening on this TCP port on any interface (we bind 0.0.0.0, as the host does). */
3
+ export function isPortFree(port) {
4
+ return new Promise((resolve) => {
5
+ const srv = createServer();
6
+ srv.once("error", () => resolve(false));
7
+ srv.once("listening", () => srv.close(() => resolve(true)));
8
+ srv.listen(port, "0.0.0.0");
9
+ });
10
+ }
11
+ /** `preferred` if free, otherwise the next free port above it (up to `span` tries). Undefined when none is free. */
12
+ export async function pickFreePort(preferred, span = 50) {
13
+ for (let p = preferred; p < preferred + span && p <= 65535; p++) {
14
+ if (await isPortFree(p))
15
+ return p;
16
+ }
17
+ return undefined;
18
+ }
19
+ //# sourceMappingURL=port.js.map
@@ -0,0 +1,142 @@
1
+ // Types and runtime validation for docs/PROTOCOL.md (WS protocol v1).
2
+ export const PROTOCOL_VERSION = 1;
3
+ export class ProtocolError extends Error {
4
+ code;
5
+ reqId;
6
+ constructor(code, message, reqId) {
7
+ super(message);
8
+ this.code = code;
9
+ this.reqId = reqId;
10
+ }
11
+ }
12
+ const ENGINES = ["gemini", "claude", "codex"];
13
+ const MODES = ["ask", "plan", "auto_edit", "auto"];
14
+ const DECISIONS = ["deny", "allow_once", "allow_session"];
15
+ const MAX_MODEL_ID = 200;
16
+ function isObj(v) {
17
+ return typeof v === "object" && v !== null && !Array.isArray(v);
18
+ }
19
+ function reqStr(o, k, reqId) {
20
+ const v = o[k];
21
+ if (typeof v !== "string" || v.length === 0) {
22
+ throw new ProtocolError("invalid_message", `field "${k}" must be a non-empty string`, reqId);
23
+ }
24
+ return v;
25
+ }
26
+ function reqIdOf(o) {
27
+ const v = o["reqId"];
28
+ if (typeof v !== "string" || v.length === 0) {
29
+ throw new ProtocolError("invalid_message", 'field "reqId" must be a non-empty string');
30
+ }
31
+ return v;
32
+ }
33
+ /** Validate an already-JSON-parsed value. Throws ProtocolError (carrying reqId when known). */
34
+ export function parseClientMessage(raw) {
35
+ if (!isObj(raw))
36
+ throw new ProtocolError("invalid_message", "message must be a JSON object");
37
+ const type = raw["type"];
38
+ if (typeof type !== "string")
39
+ throw new ProtocolError("invalid_message", 'missing "type"');
40
+ const rid = typeof raw["reqId"] === "string" ? raw["reqId"] : undefined;
41
+ switch (type) {
42
+ case "ping": {
43
+ const t = raw["t"];
44
+ return { type, ...(typeof t === "number" ? { t } : {}) };
45
+ }
46
+ case "client_hello": {
47
+ const protocol = raw["protocol"];
48
+ if (typeof protocol !== "number")
49
+ throw new ProtocolError("invalid_message", '"protocol" must be a number');
50
+ const app = typeof raw["app"] === "string" ? raw["app"] : undefined;
51
+ return { type, protocol, ...(app ? { app } : {}) };
52
+ }
53
+ case "models_list": {
54
+ const reqId = reqIdOf(raw);
55
+ const engine = reqStr(raw, "engine", reqId);
56
+ if (!ENGINES.includes(engine))
57
+ throw new ProtocolError("invalid_message", `unknown engine "${engine}"`, reqId);
58
+ return { type, reqId, engine: engine };
59
+ }
60
+ case "session_create": {
61
+ const reqId = reqIdOf(raw);
62
+ const engine = reqStr(raw, "engine", reqId);
63
+ if (!ENGINES.includes(engine))
64
+ throw new ProtocolError("invalid_message", `unknown engine "${engine}"`, reqId);
65
+ const cwd = reqStr(raw, "cwd", reqId);
66
+ const pm = raw["permissionMode"] ?? "ask";
67
+ if (typeof pm !== "string" || !MODES.includes(pm)) {
68
+ throw new ProtocolError("invalid_message", "invalid permissionMode", reqId);
69
+ }
70
+ const model = raw["model"];
71
+ if (model !== undefined && model !== null && (typeof model !== "string" || model.length === 0 || model.length > MAX_MODEL_ID)) {
72
+ throw new ProtocolError("invalid_message", '"model" must be a non-empty string', reqId);
73
+ }
74
+ return {
75
+ type,
76
+ reqId,
77
+ engine: engine,
78
+ cwd,
79
+ permissionMode: pm,
80
+ ...(typeof model === "string" ? { model } : {}),
81
+ };
82
+ }
83
+ case "session_list":
84
+ return { type, reqId: reqIdOf(raw) };
85
+ case "session_resume":
86
+ case "session_close":
87
+ case "session_delete": {
88
+ const reqId = reqIdOf(raw);
89
+ return { type, reqId, sessionId: reqStr(raw, "sessionId", reqId) };
90
+ }
91
+ case "session_rename": {
92
+ const reqId = reqIdOf(raw);
93
+ const sessionId = reqStr(raw, "sessionId", reqId);
94
+ const title = raw["title"];
95
+ if (typeof title !== "string")
96
+ throw new ProtocolError("invalid_message", '"title" must be a string', reqId);
97
+ return { type, reqId, sessionId, title };
98
+ }
99
+ case "session_set_model": {
100
+ const reqId = reqIdOf(raw);
101
+ const sessionId = reqStr(raw, "sessionId", reqId);
102
+ const model = reqStr(raw, "model", reqId);
103
+ if (model.length > MAX_MODEL_ID)
104
+ throw new ProtocolError("invalid_message", '"model" is too long', reqId);
105
+ return { type, reqId, sessionId, model };
106
+ }
107
+ case "session_attach": {
108
+ const reqId = reqIdOf(raw);
109
+ const sessionId = reqStr(raw, "sessionId", reqId);
110
+ const afterSeq = raw["afterSeq"] ?? 0;
111
+ if (typeof afterSeq !== "number" || !Number.isInteger(afterSeq) || afterSeq < 0) {
112
+ throw new ProtocolError("invalid_message", '"afterSeq" must be a non-negative integer', reqId);
113
+ }
114
+ return { type, reqId, sessionId, afterSeq };
115
+ }
116
+ case "session_detach":
117
+ return { type, sessionId: reqStr(raw, "sessionId") };
118
+ case "session_prompt": {
119
+ const reqId = reqIdOf(raw);
120
+ const sessionId = reqStr(raw, "sessionId", reqId);
121
+ const text = raw["text"];
122
+ if (typeof text !== "string" || text.length === 0) {
123
+ throw new ProtocolError("invalid_message", '"text" must be a non-empty string', reqId);
124
+ }
125
+ return { type, reqId, sessionId, text };
126
+ }
127
+ case "session_cancel":
128
+ return { type, sessionId: reqStr(raw, "sessionId", rid), ...(rid ? { reqId: rid } : {}) };
129
+ case "permission_response": {
130
+ const sessionId = reqStr(raw, "sessionId");
131
+ const requestId = reqStr(raw, "requestId");
132
+ const decision = raw["decision"];
133
+ if (typeof decision !== "string" || !DECISIONS.includes(decision)) {
134
+ throw new ProtocolError("invalid_message", "invalid decision");
135
+ }
136
+ return { type, sessionId, requestId, decision: decision };
137
+ }
138
+ default:
139
+ throw new ProtocolError("unknown_type", `unknown type "${type}"`, rid);
140
+ }
141
+ }
142
+ //# sourceMappingURL=protocol.js.map