@gleapai/kai-bridge 0.2.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.
@@ -0,0 +1,337 @@
1
+ // Preview tier A — run the app's real dev servers next to the session.
2
+ //
3
+ // Each repo may commit a `.gleap/dev.yaml`:
4
+ //
5
+ // services:
6
+ // api: { cwd: Server, run: "npm run dev", port: 9000, health: /health }
7
+ // web: { cwd: Frontend, run: "npm start", port: 3000, env: { API_URL: "http://localhost:9000" } }
8
+ // preview: web
9
+ //
10
+ // Worktree mode: every service gets a FREE port (the user's own dev server
11
+ // may already own the declared one) — `PORT` is set and `${port:<name>}`
12
+ // placeholders in `env`/`run` are substituted, so `API_URL: http://localhost:${port:api}`
13
+ // follows the re-assignment. Local mode: a service whose declared port is
14
+ // already listening is adopted as "your running dev server" instead of
15
+ // started twice.
16
+ //
17
+ // Logs go to `~/.kai/logs/services/<session>/<service>.log` so Kai can
18
+ // `tail` them; the timeline only gets one line per start/stop/failure.
19
+ // The preview URL is local (`http://localhost:<port>`) plus the LAN
20
+ // address for the phone on the same network — a public tunnel is a
21
+ // later tier (needs relay infra).
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { createServer, connect } from "node:net";
25
+ import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
26
+ import { networkInterfaces } from "node:os";
27
+ import { join, resolve } from "node:path";
28
+ import YAML from "yaml";
29
+
30
+ export const DEV_CONFIG_PATHS = [".gleap/dev.yaml", ".gleap/dev.yml"];
31
+
32
+ export function readDevConfig(repoRoot) {
33
+ for (const rel of DEV_CONFIG_PATHS) {
34
+ const p = join(repoRoot, rel);
35
+ if (!existsSync(p)) continue;
36
+ try {
37
+ const parsed = YAML.parse(readFileSync(p, "utf8")) || {};
38
+ return normalizeDevConfig(parsed);
39
+ } catch (err) {
40
+ return { services: {}, preview: null, companions: [], error: `${rel}: ${err?.message || err}` };
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export function normalizeDevConfig(raw) {
47
+ const services = {};
48
+ for (const [name, s] of Object.entries(raw?.services || {})) {
49
+ if (!s || typeof s !== "object" || !s.run) continue;
50
+ services[name] = {
51
+ name,
52
+ cwd: typeof s.cwd === "string" ? s.cwd : ".",
53
+ run: String(s.run),
54
+ port: Number.isFinite(Number(s.port)) ? Number(s.port) : null,
55
+ health: typeof s.health === "string" ? s.health : null,
56
+ env: s.env && typeof s.env === "object" ? Object.fromEntries(Object.entries(s.env).map(([k, v]) => [k, String(v)])) : {},
57
+ readyTimeoutMs: Number.isFinite(Number(s.readyTimeoutMs)) ? Number(s.readyTimeoutMs) : 90_000,
58
+ };
59
+ }
60
+ const preview = typeof raw?.preview === "string" && services[raw.preview] ? raw.preview : Object.keys(services)[0] || null;
61
+ // Companion repos this preview needs running alongside (an API for a
62
+ // frontend, etc). Entries are repo keys as reported by the device scan
63
+ // ("github.com/owner/name") — strings, or {repo, optional} objects.
64
+ // Optional companions degrade to a warning when they can't boot.
65
+ const companions = [];
66
+ for (const entry of Array.isArray(raw?.companions) ? raw.companions : []) {
67
+ const repo = typeof entry === "string" ? entry : typeof entry?.repo === "string" ? entry.repo : null;
68
+ if (!repo) continue;
69
+ companions.push({ repo: repo.toLowerCase().trim(), optional: !!(entry && typeof entry === "object" && entry.optional) });
70
+ }
71
+ return { services, preview, companions, error: null };
72
+ }
73
+
74
+ /**
75
+ * No-config fallback: infer a single dev service from the repo's root
76
+ * package.json. `.gleap/dev.yaml` stays the durable override — callers
77
+ * check `readDevConfig` first. Returns null when nothing runnable is
78
+ * found (no package.json, or no dev/start/serve script).
79
+ */
80
+ export function detectDevConfig(repoRoot) {
81
+ const pkgPath = join(repoRoot, "package.json");
82
+ if (!existsSync(pkgPath)) return null;
83
+ let pkg;
84
+ try {
85
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8")) || {};
86
+ } catch {
87
+ return null;
88
+ }
89
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
90
+ const script = ["dev", "start", "serve"].find((s) => typeof scripts[s] === "string" && scripts[s].trim());
91
+ if (!script) return null;
92
+ const pm = existsSync(join(repoRoot, "pnpm-lock.yaml"))
93
+ ? "pnpm"
94
+ : existsSync(join(repoRoot, "yarn.lock"))
95
+ ? "yarn"
96
+ : "npm";
97
+ return normalizeDevConfig({
98
+ services: {
99
+ // No declared port: ServiceRunner assigns a free one and exports
100
+ // PORT; readiness falls back to the port-listen probe.
101
+ app: { cwd: ".", run: `${pm} run ${script}`, readyTimeoutMs: 90_000 },
102
+ },
103
+ preview: "app",
104
+ });
105
+ }
106
+
107
+ function probePort(port, host) {
108
+ return new Promise((resolveP) => {
109
+ const sock = connect({ port, host });
110
+ const done = (v) => {
111
+ sock.destroy();
112
+ resolveP(v);
113
+ };
114
+ sock.once("connect", () => done(true));
115
+ sock.once("error", () => done(false));
116
+ sock.setTimeout(500, () => done(false));
117
+ });
118
+ }
119
+
120
+ export async function isPortListening(port, host) {
121
+ if (host) return probePort(port, host);
122
+ // Node 17+ resolves `localhost` to ::1 first, so dev servers often
123
+ // bind IPv6-only — an IPv4-only probe then reports "not ready" for a
124
+ // perfectly running server (bit us with Docusaurus). Check both.
125
+ const [v4, v6] = await Promise.all([probePort(port, "127.0.0.1"), probePort(port, "::1")]);
126
+ return v4 || v6;
127
+ }
128
+
129
+ export function getFreePort() {
130
+ return new Promise((resolveP, reject) => {
131
+ const srv = createServer();
132
+ srv.unref();
133
+ srv.on("error", reject);
134
+ srv.listen(0, "127.0.0.1", () => {
135
+ const { port } = srv.address();
136
+ srv.close(() => resolveP(port));
137
+ });
138
+ });
139
+ }
140
+
141
+ export function lanAddress() {
142
+ for (const list of Object.values(networkInterfaces())) {
143
+ for (const i of list || []) if (i.family === "IPv4" && !i.internal) return i.address;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /** `${port:api}` → assigned port of service `api`. */
149
+ export function substitutePorts(value, ports) {
150
+ return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? ""));
151
+ }
152
+
153
+ async function waitForReady({ port, health, timeoutMs }) {
154
+ const deadline = Date.now() + timeoutMs;
155
+ while (Date.now() < deadline) {
156
+ if (health) {
157
+ try {
158
+ // `localhost`, not 127.0.0.1 — the server may be IPv6-only (see
159
+ // isPortListening); Node's fetch tries both families.
160
+ const res = await fetch(`http://localhost:${port}${health.startsWith("/") ? health : `/${health}`}`, { signal: AbortSignal.timeout(2000) });
161
+ if (res.ok || (res.status >= 300 && res.status < 500)) return true;
162
+ } catch {
163
+ /* not yet */
164
+ }
165
+ } else if (await isPortListening(port)) {
166
+ return true;
167
+ }
168
+ await new Promise((r) => setTimeout(r, 1000));
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Runs the services of one repo binding for the lifetime of a turn (or
175
+ * longer — the daemon keeps them per session until the session ends).
176
+ */
177
+ export class ServiceRunner {
178
+ constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {} }) {
179
+ this.kaiHome = kaiHome;
180
+ this.sessionId = sessionId;
181
+ this.log = log;
182
+ this.onStatus = onStatus;
183
+ this.processes = new Map(); // name → child
184
+ this.ports = {}; // name → port
185
+ this.adopted = new Set();
186
+ this.logDir = join(kaiHome, "logs", "services", String(sessionId).replace(/[^\w.-]/g, "_"));
187
+ }
188
+
189
+ /**
190
+ * Start (or adopt) every service in `config` relative to `repoRoot`.
191
+ * Returns `{ services: [{name, port, url, logPath, adopted, ready}], preview: {name, url, lanUrl} | null }`.
192
+ */
193
+ async start(repoRoot, config, { mode = "worktree" } = {}) {
194
+ mkdirSync(this.logDir, { recursive: true });
195
+ const out = [];
196
+ // Assign ports first so cross-references resolve.
197
+ for (const svc of Object.values(config.services)) {
198
+ if (this.ports[svc.name]) continue;
199
+ const declared = svc.port;
200
+ if (mode === "local" && declared && (await isPortListening(declared))) {
201
+ this.ports[svc.name] = declared;
202
+ this.adopted.add(svc.name);
203
+ } else if (declared && !(await isPortListening(declared))) {
204
+ this.ports[svc.name] = declared;
205
+ } else {
206
+ this.ports[svc.name] = await getFreePort();
207
+ }
208
+ }
209
+ for (const svc of Object.values(config.services)) {
210
+ const port = this.ports[svc.name];
211
+ const logPath = join(this.logDir, `${svc.name}.log`);
212
+ if (this.adopted.has(svc.name)) {
213
+ out.push({ name: svc.name, port, url: `http://localhost:${port}`, logPath: null, adopted: true, ready: true });
214
+ this.onStatus(`Using your running ${svc.name} on :${port}`);
215
+ continue;
216
+ }
217
+ if (this.processes.has(svc.name)) {
218
+ out.push({ name: svc.name, port, url: `http://localhost:${port}`, logPath, adopted: false, ready: true });
219
+ continue;
220
+ }
221
+ const cwd = resolve(repoRoot, svc.cwd);
222
+ const env = {
223
+ ...process.env,
224
+ PORT: String(port),
225
+ ...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports)])),
226
+ KAI_SESSION_ID: String(this.sessionId),
227
+ BROWSER: "none",
228
+ };
229
+ const fd = openSync(logPath, "a");
230
+ // Fresh worktrees have no node_modules — running the dev command
231
+ // straight into `command not found` was the #1 preview failure.
232
+ await this.ensureDeps(cwd, { env, fd, logPath });
233
+ const cmd = substitutePorts(svc.run, this.ports);
234
+ // Under launchd the daemon's PATH is frozen at install time
235
+ // (service.mjs), so nvm/pnpm shims don't resolve with a bare
236
+ // `shell: true`. A login shell rebuilds the user's real PATH.
237
+ const child =
238
+ process.platform === "darwin"
239
+ ? spawn("/bin/zsh", ["-lc", cmd], { cwd, env, stdio: ["ignore", fd, fd], detached: true })
240
+ : spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: true });
241
+ child.on("exit", (code) => {
242
+ this.processes.delete(svc.name);
243
+ this.log("info", "service.exit", { name: svc.name, code });
244
+ if (code && code !== 0) this.onStatus(`${svc.name} exited with code ${code} — see ${logPath}`);
245
+ });
246
+ this.processes.set(svc.name, child);
247
+ this.onStatus(`Starting ${svc.name} (${svc.run}) on :${port}`);
248
+ // Bail as soon as the process dies (command not found, crash on
249
+ // boot) instead of polling a dead port for the full timeout.
250
+ const ready = await Promise.race([
251
+ waitForReady({ port, health: svc.health, timeoutMs: svc.readyTimeoutMs }),
252
+ new Promise((resolveP) => child.once("exit", () => setTimeout(() => resolveP(false), 300))),
253
+ ]);
254
+ this.onStatus(ready ? `${svc.name} ready on http://localhost:${port}` : `${svc.name} did not become ready within ${Math.round(svc.readyTimeoutMs / 1000)}s — see ${logPath}`);
255
+ out.push({ name: svc.name, port, url: `http://localhost:${port}`, logPath, adopted: false, ready });
256
+ }
257
+ const previewSvc = config.preview ? out.find((s) => s.name === config.preview) : null;
258
+ const lan = lanAddress();
259
+ return {
260
+ services: out,
261
+ preview: previewSvc ? { name: previewSvc.name, url: previewSvc.url, lanUrl: lan ? `http://${lan}:${previewSvc.port}` : null, adopted: !!previewSvc.adopted } : null,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Install JS dependencies when the service dir has a package.json but
267
+ * no node_modules (fresh worktree). Package manager by lockfile; up
268
+ * to 5 minutes; output goes to the service log. No-op otherwise.
269
+ */
270
+ async ensureDeps(cwd, { env, fd, logPath }) {
271
+ if (!existsSync(join(cwd, "package.json")) || existsSync(join(cwd, "node_modules"))) return;
272
+ const pm = existsSync(join(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(cwd, "yarn.lock")) ? "yarn" : "npm";
273
+ const installCmd = `${pm} install`;
274
+ this.onStatus(`Installing dependencies (${installCmd}) — first preview in this workspace…`);
275
+ this.log("info", "service.install", { cwd, pm });
276
+ const code = await new Promise((resolveP) => {
277
+ const child =
278
+ process.platform === "darwin"
279
+ ? spawn("/bin/zsh", ["-lc", installCmd], { cwd, env, stdio: ["ignore", fd, fd] })
280
+ : spawn(installCmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd] });
281
+ const timer = setTimeout(() => {
282
+ try {
283
+ child.kill("SIGTERM");
284
+ } catch {}
285
+ resolveP(-1);
286
+ }, 5 * 60_000);
287
+ child.on("exit", (c) => {
288
+ clearTimeout(timer);
289
+ resolveP(c ?? -1);
290
+ });
291
+ child.on("error", () => {
292
+ clearTimeout(timer);
293
+ resolveP(-1);
294
+ });
295
+ });
296
+ if (code !== 0) this.onStatus(`${installCmd} exited with code ${code} — see ${logPath}`);
297
+ }
298
+
299
+ stopAll() {
300
+ for (const [name, child] of this.processes) {
301
+ try {
302
+ process.kill(-child.pid, "SIGTERM");
303
+ } catch {
304
+ try {
305
+ child.kill("SIGTERM");
306
+ } catch {
307
+ /* gone */
308
+ }
309
+ }
310
+ this.log("info", "service.stop", { name });
311
+ }
312
+ this.processes.clear();
313
+ }
314
+
315
+ /** Prompt section telling the agent what is running + how to verify. */
316
+ describeForAgent(started) {
317
+ if (!started || started.services.length === 0) return "";
318
+ const lines = started.services.map(
319
+ (s) => `- ${s.name}: ${s.url}${s.adopted ? " (your already-running dev server)" : ""}${s.logPath ? ` · logs: ${s.logPath}` : ""}${s.ready ? "" : " (NOT ready — check the log)"}`,
320
+ );
321
+ return (
322
+ `\n\nDev services running for this session:\n${lines.join("\n")}\n` +
323
+ `Use the \`gleap_preview\` browser tools (navigate, snapshot, screenshot, console messages) to verify your change in ${started.preview?.url || "the app"} before you finish. ` +
324
+ `Tail the service logs with the Read/Bash tools if something fails.`
325
+ );
326
+ }
327
+ }
328
+
329
+ /** The Playwright MCP server registration for the turn (stdio, headless). */
330
+ export function previewMcpServer(runnerDir) {
331
+ return {
332
+ id: "gleap_preview",
333
+ name: "gleap_preview",
334
+ command: process.execPath,
335
+ args: [join(runnerDir, "..", "node_modules", "@playwright", "mcp", "cli.js"), "--headless", "--isolated"],
336
+ };
337
+ }
@@ -0,0 +1,250 @@
1
+ // Harness profiles = which login a session runs under.
2
+ //
3
+ // ambient — the user's own `~/.claude` / `~/.codex`. Read-only: the
4
+ // bridge never writes there, it only points the harness at it.
5
+ // managed — an isolated config dir under `~/.kai/accounts/<harness>/<id>/`
6
+ // seeded from ambient (settings, skills) and logged in
7
+ // separately (`kai-bridge profile login <id>`), so a second
8
+ // Claude/ChatGPT account lives side by side. Claude Code honours
9
+ // CLAUDE_CONFIG_DIR, Codex honours CODEX_HOME — that's the whole
10
+ // trick (same model as Traycer's provider profiles).
11
+ // gleap-key — no login on the device; the Server hands the bridge an API
12
+ // key per turn and bills credits as in the cloud.
13
+
14
+ import { execFileSync, spawn } from "node:child_process";
15
+ import { cpSync, existsSync, mkdirSync } from "node:fs";
16
+ import { HARNESS_IDS as REGISTRY_IDS, harnessBinary, harnessLoginCommand, probeHarnessAuth } from "./harnesses.mjs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ export const HARNESSES = REGISTRY_IDS;
22
+ const HOME = homedir();
23
+
24
+ export function ambientConfigDir(harness, home = HOME) {
25
+ if (harness === "claude") return process.env.CLAUDE_CONFIG_DIR || join(home, ".claude");
26
+ if (harness === "codex") return process.env.CODEX_HOME || join(home, ".codex");
27
+ if (harness === "cursor") return join(home, ".cursor"); // informational — Cursor has no per-profile config dir
28
+ throw new Error(`unknown harness ${harness}`);
29
+ }
30
+
31
+ export function managedConfigDir(harness, profileId, kaiHome) {
32
+ return join(kaiHome, "accounts", harness, profileId);
33
+ }
34
+
35
+ const PKG_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ".bin");
36
+
37
+ /**
38
+ * Where the harness binary is. `codex` prefers the build bundled with the
39
+ * bridge (codex-acp's own @openai/codex — the same one the ACP adapter
40
+ * spawns, so auth probes and turns agree); `claude` is the user's install.
41
+ */
42
+ export function findBinary(name) {
43
+ if (name === "claude" || name === "codex" || name === "cursor") {
44
+ const bundled = harnessBinary(name, process.env.KAI_HOME || join(HOME, ".kai"));
45
+ if (bundled) return bundled;
46
+ }
47
+ try {
48
+ return execFileSync(process.platform === "win32" ? "where" : "which", [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
49
+ .split(/\r?\n/)[0]
50
+ .trim() || null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function run(cmd, args, env, timeoutMs = 15000) {
57
+ return new Promise((resolve) => {
58
+ let out = "";
59
+ let err = "";
60
+ let done = false;
61
+ const child = spawn(cmd, args, { env, stdio: ["ignore", "pipe", "pipe"] });
62
+ const t = setTimeout(() => {
63
+ if (!done) child.kill("SIGKILL");
64
+ }, timeoutMs);
65
+ child.stdout.on("data", (d) => (out += d));
66
+ child.stderr.on("data", (d) => (err += d));
67
+ child.on("error", () => {
68
+ done = true;
69
+ clearTimeout(t);
70
+ resolve({ code: -1, out, err });
71
+ });
72
+ child.on("close", (code) => {
73
+ done = true;
74
+ clearTimeout(t);
75
+ resolve({ code, out, err });
76
+ });
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Probe a profile's auth state without touching it.
82
+ * claude: `claude auth status` exits 0 when logged in (ported from the
83
+ * retired desktop runtime's discovery.mjs).
84
+ * codex: `auth.json` with `tokens` (ChatGPT) or `OPENAI_API_KEY`.
85
+ * Returns `{ state: 'signed_in'|'signed_out'|'missing_binary', account?, version? }`.
86
+ */
87
+ export async function probeAuth(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai")) {
88
+ const bin = harnessBinary(harness, kaiHome);
89
+ if (!bin) return { state: "missing_binary" };
90
+ const auth = probeHarnessAuth(harness, configDir, kaiHome);
91
+ const version = (await run(bin, ["--version"], { ...process.env })).out.trim().split(/\r?\n/)[0] || undefined;
92
+ return { ...auth, version, binary: bin };
93
+ }
94
+
95
+ /** Create a managed profile dir seeded from ambient (settings/skills only, never credentials). */
96
+ export function createManagedProfile(harness, profileId, kaiHome) {
97
+ const dir = managedConfigDir(harness, profileId, kaiHome);
98
+ mkdirSync(dir, { recursive: true });
99
+ const ambient = ambientConfigDir(harness);
100
+ const seedable = harness === "claude" ? ["settings.json", "skills", "plugins", "CLAUDE.md"] : ["config.toml", "skills"];
101
+ for (const name of seedable) {
102
+ const src = join(ambient, name);
103
+ if (existsSync(src) && !existsSync(join(dir, name))) {
104
+ try {
105
+ cpSync(src, join(dir, name), { recursive: true });
106
+ } catch {
107
+ /* best-effort seed */
108
+ }
109
+ }
110
+ }
111
+ return dir;
112
+ }
113
+
114
+ /** Interactive login for a profile (opens the harness's own flow). */
115
+ export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai")) {
116
+ const c = harnessLoginCommand(harness, configDir, kaiHome);
117
+ if (!c) return null;
118
+ return { cmd: c.cmd, args: c.args, env: { ...process.env, ...c.env } };
119
+ }
120
+
121
+ /**
122
+ * Both harness logins are interactive (claude prints "Please run /login"
123
+ * when headless), so a remote "Sign in" opens a terminal window ON THE
124
+ * DEVICE running the login under the profile's config dir. Returns the
125
+ * spawned process or null when no terminal could be opened.
126
+ */
127
+ export function openLoginTerminal(harness, configDir) {
128
+ const c = loginCommand(harness, configDir);
129
+ if (!c) return null;
130
+ // Ambient claude must log in with the env untouched so credentials land
131
+ // in the keychain, where the (equally untouched) probe and turns look.
132
+ const claudeAmbient = harness === "claude" && resolve(configDir) === resolve(ambientConfigDir("claude"));
133
+ const envPrefix =
134
+ harness === "claude"
135
+ ? claudeAmbient
136
+ ? ""
137
+ : `CLAUDE_CONFIG_DIR=${shellQuote(configDir)} `
138
+ : harness === "codex"
139
+ ? `CODEX_HOME=${shellQuote(configDir)} `
140
+ : "NO_OPEN_BROWSER=0 ";
141
+ const line = `${envPrefix}${shellQuote(c.cmd)} login`;
142
+ if (process.platform === "darwin") {
143
+ return spawn("osascript", ["-e", `tell application "Terminal" to activate`, "-e", `tell application "Terminal" to do script ${JSON.stringify(line)}`], { stdio: "ignore", detached: true });
144
+ }
145
+ if (process.platform === "win32") {
146
+ return spawn("cmd", ["/c", "start", "cmd", "/k", line.replace(/^([A-Z_]+)=('[^']*') /, "set $1=$2 && ")], { stdio: "ignore", detached: true, shell: true });
147
+ }
148
+ for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) {
149
+ if (findBinary(term)) {
150
+ const args = term === "gnome-terminal" ? ["--", "bash", "-lc", line] : ["-e", `bash -lc ${shellQuote(line)}`];
151
+ return spawn(term, args, { stdio: "ignore", detached: true });
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
157
+ const shellQuote = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
158
+
159
+ /** Shape reported to the Server in `hello`. */
160
+ export async function describeProfiles(profiles, usageByProfile = null) {
161
+ const out = [];
162
+ for (const p of profiles) {
163
+ const auth = await probeAuth(p.harness, p.configDir);
164
+ out.push({
165
+ id: p.id,
166
+ harness: p.harness,
167
+ kind: p.kind,
168
+ label: p.label,
169
+ authState: auth.state,
170
+ account: auth.account ?? null,
171
+ version: auth.version ?? null,
172
+ rateLimit: p.rateLimit ?? null,
173
+ usageLimits: usageByProfile?.get?.(p.id) ?? null,
174
+ });
175
+ }
176
+ return out;
177
+ }
178
+
179
+ // ── Plan-usage limits (Claude Code) ──────────────────────────────────
180
+ //
181
+ // `claude -p "/usage"` prints the same windows the CLI's own usage
182
+ // panel shows (5-hour session, weekly all-models, weekly per-model),
183
+ // ~2s with --strict-mcp-config since no MCP servers spawn. Subscription
184
+ // logins only — an API-key login prints no window lines and parses to
185
+ // null. Codex has no equivalent surface today.
186
+ //
187
+ // Current session: 3% used · resets Aug 24 at 4:50pm (Europe/Vienna)
188
+ // Current week (all models): 0% used · resets Aug 27 at 8am (…)
189
+ // Current week (Fable): 0% used · resets Aug 27 at 8am (…)
190
+ export function parseClaudeUsageOutput(text) {
191
+ const windows = [];
192
+ const push = (id, label, line) => {
193
+ const m = /:\s*(?:(<?\s*\d+(?:\.\d+)?)%\s*used)\s*(?:·\s*resets\s*(.+?))?\s*$/i.exec(line);
194
+ if (!m) return;
195
+ const percent = Number(String(m[1]).replace("<", "").trim());
196
+ if (!Number.isFinite(percent)) return;
197
+ windows.push({
198
+ id,
199
+ label,
200
+ usedPercent: Math.max(0, Math.min(100, percent)),
201
+ // Keep the CLI's own phrasing, minus the timezone parenthesis —
202
+ // parsing "Aug 24 at 4:50pm" into a Date is locale quicksand.
203
+ resetsText: (m[2] || "").replace(/\s*\([^)]*\)\s*$/, "").trim() || null,
204
+ });
205
+ };
206
+ for (const raw of String(text || "").split(/\r?\n/)) {
207
+ const line = raw.trim();
208
+ if (/^current session:/i.test(line)) push("session", "5-hour limit", line);
209
+ else if (/^current week \(all models\):/i.test(line)) push("week-all", "Weekly · all models", line);
210
+ else {
211
+ const m = /^current week \(([^)]+)\):/i.exec(line);
212
+ if (m && m[1].toLowerCase() !== "all models") push("week-model", `Weekly · ${m[1]}`, line);
213
+ }
214
+ }
215
+ return windows;
216
+ }
217
+
218
+ const SUBSCRIPTION_LABELS = { pro: "Pro", max: "Max", team: "Team", enterprise: "Enterprise" };
219
+
220
+ /**
221
+ * Fetch the plan-usage windows for one profile. Returns
222
+ * `{ plan, windows, fetchedAt }` or null (not claude, signed out,
223
+ * API-key login, or the CLI changed its output). Never throws.
224
+ */
225
+ export async function probeUsageLimits(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai")) {
226
+ if (harness !== "claude") return null;
227
+ const bin = harnessBinary(harness, kaiHome);
228
+ if (!bin) return null;
229
+ const env = { ...process.env };
230
+ delete env.ANTHROPIC_API_KEY;
231
+ // Ambient = the CLI's own default dir; the env must stay untouched so
232
+ // macOS keychain credentials resolve (same rule as probes and turns).
233
+ if (resolve(configDir) === resolve(ambientConfigDir("claude"))) delete env.CLAUDE_CONFIG_DIR;
234
+ else env.CLAUDE_CONFIG_DIR = configDir;
235
+ const res = await run(bin, ["-p", "/usage", "--strict-mcp-config"], env, 30_000);
236
+ if (res.code !== 0) return null;
237
+ const windows = parseClaudeUsageOutput(res.out);
238
+ if (windows.length === 0) return null;
239
+ let plan = null;
240
+ const status = await run(bin, ["auth", "status", "--json"], env, 15_000);
241
+ if (status.code === 0) {
242
+ try {
243
+ const type = String(JSON.parse(status.out)?.subscriptionType || "").toLowerCase();
244
+ plan = SUBSCRIPTION_LABELS[type] ?? (type ? type[0].toUpperCase() + type.slice(1) : null);
245
+ } catch {
246
+ /* label is cosmetic */
247
+ }
248
+ }
249
+ return { source: "subscription", plan, windows, fetchedAt: new Date().toISOString() };
250
+ }