@gleapai/kai-bridge 0.9.0 → 0.10.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/src/ps.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // `kai-bridge ps` — what this machine is running right now, grouped by
2
+ // Kai Code session. Read-only: it never talks to the daemon, it reads
3
+ // the files the daemon keeps for crash recovery (daemon.mjs):
4
+ //
5
+ // ~/.kai/daemon.lock pid of the running daemon
6
+ // ~/.kai/state/inflight.json turns in progress (+ runner pid, session meta)
7
+ // ~/.kai/state/preview-pids.json dev-server processes (+ port, session meta)
8
+ //
9
+ // Liveness is `kill -0`. Dead pids are LABELLED, never cleaned up — the
10
+ // daemon owns those files (reportInterruptedTurns / adoptPreviewPids).
11
+ // `collectProcessList` is the one source of truth for the text view,
12
+ // `--json`, and any host UI (a menu-bar widget) that wants the same list.
13
+
14
+ import { readFileSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ import { KAI_HOME } from "./config.mjs";
18
+ import { installedVersionOrNull } from "./selfupdate.mjs";
19
+
20
+ export function pidAlive(pid) {
21
+ if (!Number.isInteger(pid) || pid <= 0) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ // EPERM: the process exists but belongs to someone else — still alive.
27
+ return err?.code === "EPERM";
28
+ }
29
+ }
30
+
31
+ function readJson(path, fallback) {
32
+ try {
33
+ return JSON.parse(readFileSync(path, "utf8"));
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
38
+
39
+ function readDaemon(kaiHome, isAlive, version) {
40
+ const lock = join(kaiHome, "daemon.lock");
41
+ let pid = null;
42
+ let startedAt = null;
43
+ try {
44
+ pid = Number(readFileSync(lock, "utf8").trim()) || null;
45
+ // The lock is written once, when the daemon starts (acquireLock).
46
+ startedAt = statSync(lock).mtime.toISOString();
47
+ } catch {
48
+ /* no lock — no daemon */
49
+ }
50
+ const running = pid != null && isAlive(pid);
51
+ return { running, pid, version, startedAt: running ? startedAt : null };
52
+ }
53
+
54
+ /** Pre-0.5.0 inflight files held bare turn ids. */
55
+ function normaliseInflight(raw) {
56
+ return (Array.isArray(raw) ? raw : []).map((e) => (typeof e === "string" ? { turnId: e } : e)).filter((e) => e && typeof e.turnId === "string");
57
+ }
58
+
59
+ /**
60
+ * `{ daemon: { running, pid, version, startedAt }, sessions: [{ sessionId,
61
+ * title, sessionUrl, repos, turns: [...], services: [...] }] }`.
62
+ * Turn state: running | exited (runner pid gone, daemon still up) |
63
+ * interrupted (daemon down). Service state: running | dead.
64
+ */
65
+ export function collectProcessList({ kaiHome = KAI_HOME, isAlive = pidAlive, version = installedVersionOrNull() } = {}) {
66
+ const daemon = readDaemon(kaiHome, isAlive, version);
67
+ const inflight = normaliseInflight(readJson(join(kaiHome, "state", "inflight.json"), []));
68
+ const previewPids = readJson(join(kaiHome, "state", "preview-pids.json"), []);
69
+ const services = (Array.isArray(previewPids) ? previewPids : []).filter((e) => e && Number.isInteger(e.pid));
70
+
71
+ const sessions = new Map(); // sessionId (or null) → session block, in first-seen order
72
+ const sessionFor = (entry) => {
73
+ const id = typeof entry.sessionId === "string" ? entry.sessionId : null;
74
+ let s = sessions.get(id);
75
+ if (!s) {
76
+ s = { sessionId: id, title: null, sessionUrl: null, repos: [], turns: [], services: [] };
77
+ sessions.set(id, s);
78
+ }
79
+ // Every entry denormalises the session meta; the first one that has it wins.
80
+ if (!s.title && typeof entry.title === "string") s.title = entry.title;
81
+ if (!s.sessionUrl && typeof entry.sessionUrl === "string") s.sessionUrl = entry.sessionUrl;
82
+ if (!s.repos.length && Array.isArray(entry.repos)) s.repos = entry.repos.filter((r) => typeof r === "string");
83
+ return s;
84
+ };
85
+
86
+ for (const e of inflight) {
87
+ const pid = Number.isInteger(e.pid) ? e.pid : null;
88
+ const state = !daemon.running ? "interrupted" : pid != null && !isAlive(pid) ? "exited" : "running";
89
+ sessionFor(e).turns.push({ turnId: e.turnId, agent: e.agent ?? null, harness: e.harness ?? null, profileId: e.profileId ?? null, pid, startedAt: e.startedAt ?? null, state });
90
+ }
91
+ for (const e of services) {
92
+ sessionFor(e).services.push({ name: e.name ?? null, pid: e.pid, port: Number.isInteger(e.port) ? e.port : null, startedAt: e.startedAt ?? null, state: isAlive(e.pid) ? "running" : "dead" });
93
+ }
94
+ return { daemon, sessions: [...sessions.values()] };
95
+ }
96
+
97
+ // ── text view ───────────────────────────────────────────────────────
98
+
99
+ /** "2h 13m", "3m 12s", "45s" — how long since `iso`. */
100
+ export function formatAge(iso, now = Date.now()) {
101
+ const ms = iso ? now - Date.parse(iso) : NaN;
102
+ if (!Number.isFinite(ms) || ms < 0) return "?";
103
+ const s = Math.floor(ms / 1000);
104
+ if (s < 60) return `${s}s`;
105
+ const m = Math.floor(s / 60);
106
+ if (m < 60) return `${m}m ${s % 60}s`;
107
+ const h = Math.floor(m / 60);
108
+ if (h < 24) return `${h}h ${m % 60}m`;
109
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
110
+ }
111
+
112
+ /** Minutes-and-up version for long-lived dev servers ("14m", "2h 13m"). */
113
+ function formatUptime(iso, now) {
114
+ const age = formatAge(iso, now);
115
+ return age.endsWith("s") && !age.includes("m") ? age : age.replace(/ \d+s$/, "");
116
+ }
117
+
118
+ function turnLine(t, now) {
119
+ const who = [t.agent ?? "turn", t.harness && t.profileId ? `${t.harness}/${t.profileId}` : t.harness ?? ""].filter(Boolean);
120
+ const state =
121
+ t.state === "running" ? `running ${formatAge(t.startedAt, now)}` : t.state === "exited" ? "exited — runner gone, daemon still winding it down" : "interrupted — daemon is down";
122
+ return ` turn ${t.turnId.padEnd(26)} ${who[0].padEnd(16)} ${(who[1] ?? "").padEnd(16)} ${(t.pid != null ? `pid ${t.pid}` : "").padEnd(10)} ${state}`;
123
+ }
124
+
125
+ function serviceLine(s, now) {
126
+ const state = s.state === "running" ? `up ${formatUptime(s.startedAt, now)}` : "dead — stale entry";
127
+ return ` dev ${(s.name ?? "?").padEnd(26)} ${"".padEnd(16)} ${"".padEnd(16)} ${`pid ${s.pid}`.padEnd(10)} ${(s.port != null ? `:${s.port}` : "").padEnd(7)} ${state}`;
128
+ }
129
+
130
+ export function formatProcessList(list, { now = Date.now() } = {}) {
131
+ const lines = [];
132
+ const d = list.daemon;
133
+ lines.push(
134
+ d.running
135
+ ? `daemon: running · pid ${d.pid}${d.version ? ` · v${d.version}` : ""} · up ${formatUptime(d.startedAt, now)}`
136
+ : `daemon: not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""} — run \`kai-bridge start\` or \`kai-bridge install\``,
137
+ );
138
+ if (!list.sessions.length) {
139
+ lines.push("", "nothing running on this machine");
140
+ return lines.join("\n");
141
+ }
142
+ for (const s of list.sessions) {
143
+ lines.push("");
144
+ const head = [`session ${s.sessionId ?? "(unknown)"}`, s.title ? `"${s.title}"` : "(untitled)"];
145
+ if (s.repos.length) head.push(s.repos.join(", "));
146
+ lines.push(head.join(" "));
147
+ if (s.sessionUrl) lines.push(` ${s.sessionUrl}`);
148
+ for (const t of s.turns) lines.push(turnLine(t, now));
149
+ for (const sv of s.services) lines.push(serviceLine(sv, now));
150
+ }
151
+ return lines.join("\n");
152
+ }
@@ -27,7 +27,7 @@ export function locateRepository(rawPath, repoKey) {
27
27
  return target;
28
28
  }
29
29
 
30
- export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000 }, spawnGit = spawn) {
30
+ export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000, gitEnv = null }, spawnGit = spawn) {
31
31
  if (typeof remote !== 'string' || !(/^(https?|ssh):\/\/\S+$/i.test(remote) || /^[\w.-]+@[\w.-]+:\S+$/.test(remote))) {
32
32
  return Promise.reject(new Error('This repository has an unsupported clone URL.'));
33
33
  }
@@ -53,7 +53,7 @@ export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_0
53
53
  stdio: ['ignore', 'ignore', 'pipe'],
54
54
  detached: process.platform !== 'win32',
55
55
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', SSH_ASKPASS: 'echo',
56
- GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' },
56
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes', ...(gitEnv || {}) },
57
57
  });
58
58
  let stderr = '';
59
59
  let timedOut = false;
package/src/service.mjs CHANGED
@@ -60,13 +60,6 @@ ${envXml}
60
60
  `;
61
61
  }
62
62
 
63
- /** The desktop-session variables a headed browser needs (Linux only). */
64
- export function displayEnv(env = process.env) {
65
- const out = {};
66
- for (const k of ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) if (env[k]) out[k] = env[k];
67
- return out;
68
- }
69
-
70
63
  export function renderSystemdUnit({ program, args, env = {} }) {
71
64
  const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
72
65
  return `[Unit]
@@ -102,10 +95,6 @@ export function install({ binPath, logDir, env = {} }) {
102
95
  PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
103
96
  HOME: homedir(),
104
97
  ...(process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}),
105
- // Linux: a systemd --user unit starts without the desktop session's
106
- // display — the headed sign-in window (preview-login.mjs) needs it.
107
- // Captured at install time, like PATH.
108
- ...(os === "linux" ? displayEnv(process.env) : {}),
109
98
  ...env,
110
99
  };
111
100
  if (os === "darwin") {
package/src/setup.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // 1. pair this machine with the user's Gleap account
4
4
  // 2. offer the background service (start at login, restart on crash)
5
5
  // 3. offer harness sign-ins (Claude Code / Codex / Cursor), inline
6
- // 4. make sure a browser exists for previews / Verify (Chrome, else Chromium)
6
+ // 4. make sure a browser exists for previews (Chrome, else Chromium)
7
7
  // 5. say it's done, nicely
8
8
  //
9
9
  // Runs from `kai-bridge setup`, from a bare `kai-bridge` on an unpaired
@@ -247,10 +247,10 @@ export async function runSetup({ binPath, prompter = makePrompter() } = {}) {
247
247
  out("");
248
248
 
249
249
  // 4 ── Browser for previews --------------------------------------
250
- // Verify turns drive the preview in a real browser. System Chrome
251
- // needs nothing; otherwise Playwright's Chromium is downloaded once
252
- // here (the daemon would do it lazily before the first verify turn,
253
- // which is a bad moment to wait for 150 MB).
250
+ // The preview warm-up and the agent's browser tools need a real
251
+ // browser. System Chrome needs nothing; otherwise Playwright's Chromium
252
+ // is downloaded once here (the daemon would do it lazily before the
253
+ // first preview, which is a bad moment to wait for 150 MB).
254
254
  out("Step 4 — browser for previews:");
255
255
  try {
256
256
  const { ensurePreviewBrowser } = await import("./preview.mjs");
@@ -0,0 +1,109 @@
1
+ // Where `cloudflared` comes from, in order: the machine's own (PATH, so a
2
+ // `brew install cloudflared` wins), the copy this bridge downloaded before
3
+ // (`~/.kai/bin`), else a download of the pinned release from Cloudflare's
4
+ // GitHub, checked against the SHA-256 published in that release's notes.
5
+ // Anything else → `dependency_missing` with the install hint; the
6
+ // dashboard renders it and "Make public" retries.
7
+ import { spawn, execFile } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import { PreviewError } from "./preview-errors.mjs";
13
+
14
+ const exec = promisify(execFile);
15
+
16
+ export const CLOUDFLARED_VERSION = "2026.9.1";
17
+ /**
18
+ * Release assets + their SHA-256 — the `assets[].digest` of
19
+ * https://api.github.com/repos/cloudflare/cloudflared/releases/tags/2026.9.1
20
+ * (the checksum block in the release NOTES hashes the unpacked macOS
21
+ * binaries, not the .tgz files that get downloaded).
22
+ */
23
+ export const CLOUDFLARED_ASSETS = {
24
+ "darwin-x64": { asset: "cloudflared-darwin-amd64.tgz", sha256: "ff0d3b51d5ff70eceef89d6b32145fee985018a2174596a5dbe405e2766e2ac4" },
25
+ "darwin-arm64": { asset: "cloudflared-darwin-arm64.tgz", sha256: "c27ab8fd0aa489449e3d201eb02f957ef460a13b613662928b1b23394bf1bcfe" },
26
+ "linux-x64": { asset: "cloudflared-linux-amd64", sha256: "03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc" },
27
+ "linux-arm64": { asset: "cloudflared-linux-arm64", sha256: "3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3" },
28
+ "win32-x64": { asset: "cloudflared-windows-amd64.exe", sha256: "2837888cc0f5d58f15b6dc478376de90b4d3ba5241c7947455d1e0a0df429712" },
29
+ };
30
+
31
+ export const INSTALL_HINT = "Install it (macOS: brew install cloudflared · Debian/Ubuntu: apt install cloudflared · Windows: winget install Cloudflare.cloudflared) and make the preview public again.";
32
+
33
+ const missing = (device, why) =>
34
+ new PreviewError(`cloudflared is not installed on ${device}${why ? ` (${why})` : ""}. ${INSTALL_HINT}`, { code: "dependency_missing", kind: "machine", detail: { dependency: "cloudflared" } });
35
+
36
+ /** `command -v cloudflared` through a login shell on macOS (launchd PATH is frozen at install time). */
37
+ export function commandOnPath(cmd, spawnImpl = spawn) {
38
+ return new Promise((resolveP) => {
39
+ const probe =
40
+ process.platform === "win32"
41
+ ? spawnImpl("where", [cmd], { stdio: "ignore", windowsHide: true })
42
+ : process.platform === "darwin"
43
+ ? spawnImpl("/bin/zsh", ["-lc", `command -v ${cmd}`], { stdio: "ignore" })
44
+ : spawnImpl("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" });
45
+ probe.on("exit", (c) => resolveP(c === 0));
46
+ probe.on("error", () => resolveP(false));
47
+ });
48
+ }
49
+
50
+ export const managedBinaryPath = (kaiHome) => join(kaiHome, "bin", process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
51
+
52
+ /**
53
+ * Resolve the binary to run. `download` / `onPath` are injectable for tests.
54
+ * Returns the command (bare `cloudflared` when it is on PATH, else a full path).
55
+ */
56
+ export async function resolveCloudflared({ kaiHome, device = "this device", log = () => {}, onPath = commandOnPath, download = downloadCloudflared } = {}) {
57
+ if (await onPath("cloudflared")) return "cloudflared";
58
+ const managed = managedBinaryPath(kaiHome);
59
+ if (existsSync(managed)) return managed;
60
+ const target = CLOUDFLARED_ASSETS[`${process.platform}-${process.arch}`];
61
+ if (!target) throw missing(device, `no download for ${process.platform}-${process.arch}`);
62
+ try {
63
+ log("info", "tunnel.binary.download", { version: CLOUDFLARED_VERSION, asset: target.asset });
64
+ await download({ target, managed, kaiHome });
65
+ return managed;
66
+ } catch (err) {
67
+ log("warn", "tunnel.binary.download_failed", { error: err?.message });
68
+ throw missing(device, `download failed: ${err?.message || err}`);
69
+ }
70
+ }
71
+
72
+ /** Fetch the pinned asset, verify its SHA-256, unpack (macOS ships a .tgz), install atomically. */
73
+ export async function downloadCloudflared({ target, managed, kaiHome }) {
74
+ const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${target.asset}`;
75
+ const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
76
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
77
+ const bytes = Buffer.from(await res.arrayBuffer());
78
+ const sum = createHash("sha256").update(bytes).digest("hex");
79
+ if (sum !== target.sha256) throw new Error(`checksum mismatch for ${target.asset}`);
80
+ const dir = join(kaiHome, "bin");
81
+ mkdirSync(dir, { recursive: true });
82
+ const tmp = join(dir, `.${target.asset}.${process.pid}`);
83
+ writeFileSync(tmp, bytes);
84
+ try {
85
+ if (target.asset.endsWith(".tgz")) {
86
+ const unpack = join(dir, `.unpack-${process.pid}`);
87
+ mkdirSync(unpack, { recursive: true });
88
+ await exec("tar", ["-xzf", tmp, "-C", unpack]);
89
+ renameSync(join(unpack, "cloudflared"), managed);
90
+ rmSync(unpack, { recursive: true, force: true });
91
+ } else {
92
+ renameSync(tmp, managed);
93
+ }
94
+ } finally {
95
+ rmSync(tmp, { force: true });
96
+ }
97
+ if (process.platform !== "win32") chmodSync(managed, 0o755);
98
+ writeFileSync(join(dir, "cloudflared.version"), CLOUDFLARED_VERSION);
99
+ return managed;
100
+ }
101
+
102
+ /** The version a managed copy was downloaded as (null for a PATH install). */
103
+ export const managedVersion = (kaiHome) => {
104
+ try {
105
+ return readFileSync(join(kaiHome, "bin", "cloudflared.version"), "utf8").trim() || null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ };
package/src/tunnel.mjs ADDED
@@ -0,0 +1,227 @@
1
+ // Public preview links — the device side. Runs ONE `cloudflared` for this
2
+ // device (the Server owns the tunnel and its hostnames, see
3
+ // `POST /devices/me/public-hosts`) and owns the hostname → local port
4
+ // route table. Routes are ref-counted by session so two sessions sharing
5
+ // an adopted companion (two web repos, one API) keep one route alive
6
+ // until the last of them stops.
7
+ //
8
+ // A locally-managed cloudflared does NOT reload its config file, so every
9
+ // route change writes `~/.kai/tunnel/config.yml` and swaps the process:
10
+ // the new one starts first, the old one is stopped once the new one has
11
+ // registered (the documented zero-downtime way, a "replica" for a second).
12
+ //
13
+ // State under KAI_HOME: tunnel/credentials.json (0600), tunnel/config.yml,
14
+ // logs/tunnel.log, state/tunnel.json (pid + routes, so a restarted daemon
15
+ // can kill a survivor and re-route the previews it resumed).
16
+ import { spawn } from "node:child_process";
17
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ export const RESTART_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000];
21
+ /** How long a fresh cloudflared gets to register before the old one is stopped anyway. */
22
+ export const SWAP_READY_TIMEOUT_MS = 10_000;
23
+
24
+ /** `config.yml` — one ingress rule per hostname, catch-all last (cloudflared refuses a file without it). */
25
+ export function renderTunnelConfig({ tunnelId, credentialsPath, routes }) {
26
+ const lines = [`tunnel: ${tunnelId}`, `credentials-file: ${credentialsPath}`, "no-autoupdate: true", "ingress:"];
27
+ for (const [hostname, r] of [...routes].sort(([a], [b]) => a.localeCompare(b))) {
28
+ lines.push(` - hostname: ${hostname}`, ` service: ${r.protocol}://localhost:${r.port}`, " originRequest:");
29
+ // Vite 7 / Next compare Host with their allowed hosts — hand the app the Host it expects.
30
+ lines.push(` httpHostHeader: localhost:${r.port}`);
31
+ if (r.protocol === "https") lines.push(" noTLSVerify: true");
32
+ }
33
+ lines.push(" - service: http_status:404");
34
+ return lines.join("\n") + "\n";
35
+ }
36
+
37
+ export class TunnelManager {
38
+ /** `spawnImpl` / `now` / `sleep` are injectable for tests. */
39
+ constructor({ kaiHome, log = () => {}, spawnImpl = spawn, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
40
+ this.kaiHome = kaiHome;
41
+ this.log = log;
42
+ this.spawnImpl = spawnImpl;
43
+ this.sleep = sleep;
44
+ this.dir = join(kaiHome, "tunnel");
45
+ this.credentialsPath = join(this.dir, "credentials.json");
46
+ this.configPath = join(this.dir, "config.yml");
47
+ this.logPath = join(kaiHome, "logs", "tunnel.log");
48
+ this.statePath = join(kaiHome, "state", "tunnel.json");
49
+ this.binary = "cloudflared";
50
+ this.tunnelId = null;
51
+ this.routes = new Map(); // hostname → { port, protocol, owners: Set<sessionId> }
52
+ this.child = null;
53
+ this.restarts = 0;
54
+ this.applying = Promise.resolve();
55
+ }
56
+
57
+ /** Credentials from the Server (`{ AccountTag, TunnelID, TunnelSecret }`), written only when they changed. */
58
+ ensureRunning({ tunnelId, credentials }) {
59
+ mkdirSync(this.dir, { recursive: true });
60
+ const json = JSON.stringify(credentials);
61
+ let current = null;
62
+ try {
63
+ current = readFileSync(this.credentialsPath, "utf8");
64
+ } catch {
65
+ /* first time */
66
+ }
67
+ if (current !== json) {
68
+ writeFileSync(this.credentialsPath, json, { mode: 0o600 });
69
+ chmodSync(this.credentialsPath, 0o600);
70
+ }
71
+ this.tunnelId = tunnelId;
72
+ }
73
+
74
+ /** Replace this session's routes; a hostname another session still uses just gains an owner (or moves port). */
75
+ setRoutes(sessionId, routes) {
76
+ for (const r of this.routes.values()) r.owners.delete(sessionId);
77
+ for (const { hostname, port, protocol = "http" } of routes) {
78
+ const existing = this.routes.get(hostname);
79
+ if (existing) {
80
+ existing.port = port;
81
+ existing.protocol = protocol;
82
+ existing.owners.add(sessionId);
83
+ } else {
84
+ this.routes.set(hostname, { port, protocol, owners: new Set([sessionId]) });
85
+ }
86
+ }
87
+ this.prune();
88
+ return this.apply();
89
+ }
90
+
91
+ removeSession(sessionId) {
92
+ for (const r of this.routes.values()) r.owners.delete(sessionId);
93
+ this.prune();
94
+ return this.apply();
95
+ }
96
+
97
+ routesFor(sessionId) {
98
+ return [...this.routes].filter(([, r]) => r.owners.has(sessionId)).map(([hostname, r]) => ({ hostname, port: r.port, protocol: r.protocol }));
99
+ }
100
+
101
+ prune() {
102
+ for (const [hostname, r] of this.routes) if (r.owners.size === 0) this.routes.delete(hostname);
103
+ }
104
+
105
+ /** Serialised: the config is rewritten and the process swapped (or stopped when nothing is routed). */
106
+ apply() {
107
+ this.applying = this.applying.then(() => this.applyNow()).catch((err) => this.log("warn", "tunnel.apply.failed", { error: err?.message }));
108
+ return this.applying;
109
+ }
110
+
111
+ async applyNow() {
112
+ this.saveState();
113
+ if (this.routes.size === 0) {
114
+ await this.stop();
115
+ return;
116
+ }
117
+ if (!this.tunnelId) throw new Error("tunnel credentials missing");
118
+ mkdirSync(this.dir, { recursive: true });
119
+ writeFileSync(this.configPath, renderTunnelConfig({ tunnelId: this.tunnelId, credentialsPath: this.credentialsPath, routes: this.routes }));
120
+ const old = this.child;
121
+ this.child = null;
122
+ const next = await this.spawnCloudflared();
123
+ if (old) await this.kill(old);
124
+ this.child = next;
125
+ this.saveState();
126
+ }
127
+
128
+ /** Start a cloudflared on the current config; resolves once it registered with the edge (or after the cap). */
129
+ async spawnCloudflared() {
130
+ mkdirSync(join(this.kaiHome, "logs"), { recursive: true });
131
+ const fd = openSync(this.logPath, "a");
132
+ const child = this.spawnImpl(this.binary, ["tunnel", "--config", this.configPath, "--no-autoupdate", "run", this.tunnelId], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
133
+ let ready;
134
+ const registered = new Promise((r) => (ready = r));
135
+ const tee = (chunk) => {
136
+ const text = String(chunk);
137
+ try {
138
+ writeFileSync(fd, text);
139
+ } catch {
140
+ /* log rotated away */
141
+ }
142
+ if (/Registered tunnel connection/i.test(text)) ready();
143
+ };
144
+ child.stdout?.on("data", tee);
145
+ child.stderr?.on("data", tee);
146
+ child.on("exit", (code, signal) => {
147
+ closeSync(fd);
148
+ ready();
149
+ if (this.child !== child) return; // replaced or stopped on purpose
150
+ this.child = null;
151
+ this.log("warn", "tunnel.exited", { code, signal });
152
+ if (this.routes.size === 0) return;
153
+ // Crashed while previews are public: back off and come back.
154
+ const delay = RESTART_BACKOFF_MS[Math.min(this.restarts, RESTART_BACKOFF_MS.length - 1)];
155
+ this.restarts += 1;
156
+ this.sleep(delay).then(() => {
157
+ if (!this.child && this.routes.size > 0) this.apply();
158
+ });
159
+ });
160
+ child.on("error", (err) => this.log("error", "tunnel.spawn.failed", { error: err?.message }));
161
+ await Promise.race([registered, this.sleep(SWAP_READY_TIMEOUT_MS)]);
162
+ this.restarts = child.exitCode === null ? 0 : this.restarts;
163
+ return child;
164
+ }
165
+
166
+ async kill(child) {
167
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
168
+ const gone = new Promise((r) => child.once("exit", r));
169
+ try {
170
+ child.kill("SIGTERM");
171
+ } catch {
172
+ return;
173
+ }
174
+ await Promise.race([gone, this.sleep(5_000).then(() => child.kill("SIGKILL"))]);
175
+ }
176
+
177
+ async stop() {
178
+ const child = this.child;
179
+ this.child = null;
180
+ await this.kill(child);
181
+ this.saveState();
182
+ }
183
+
184
+ /**
185
+ * Daemon start: end a cloudflared the previous daemon life left behind,
186
+ * keep the routes of sessions whose previews were resumed, drop the rest.
187
+ */
188
+ async resume({ liveSessionIds = [], resolveBinary = null } = {}) {
189
+ let state = null;
190
+ try {
191
+ state = JSON.parse(readFileSync(this.statePath, "utf8"));
192
+ } catch {
193
+ /* nothing to resume */
194
+ }
195
+ if (Number.isInteger(state?.pid)) {
196
+ try {
197
+ process.kill(state.pid, "SIGTERM");
198
+ } catch {
199
+ /* already gone */
200
+ }
201
+ }
202
+ const live = new Set(liveSessionIds);
203
+ for (const [hostname, r] of Object.entries(state?.routes || {})) {
204
+ const owners = new Set((r.owners || []).filter((s) => live.has(s)));
205
+ if (owners.size) this.routes.set(hostname, { port: r.port, protocol: r.protocol || "http", owners });
206
+ }
207
+ if (this.routes.size && state?.tunnelId && existsSync(this.credentialsPath)) {
208
+ this.tunnelId = state.tunnelId;
209
+ // The binary lives wherever "Make public" found it (PATH or ~/.kai/bin) — ask again.
210
+ if (resolveBinary) this.binary = await resolveBinary();
211
+ await this.apply();
212
+ } else {
213
+ this.routes.clear();
214
+ this.saveState();
215
+ }
216
+ }
217
+
218
+ saveState() {
219
+ try {
220
+ mkdirSync(join(this.kaiHome, "state"), { recursive: true });
221
+ const routes = Object.fromEntries([...this.routes].map(([h, r]) => [h, { port: r.port, protocol: r.protocol, owners: [...r.owners] }]));
222
+ writeFileSync(this.statePath, JSON.stringify({ pid: this.child?.pid ?? null, tunnelId: this.tunnelId, routes }), { mode: 0o600 });
223
+ } catch (err) {
224
+ this.log("warn", "tunnel.state.failed", { error: err?.message });
225
+ }
226
+ }
227
+ }