@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.
- package/README.md +45 -0
- package/bin/kai-bridge.mjs +275 -0
- package/package.json +47 -0
- package/runner/acp-runner.mjs +671 -0
- package/runner/lib/acp/harnesses.mjs +342 -0
- package/runner/lib/acp/mapper.mjs +575 -0
- package/runner/lib/acp/transcripts.mjs +238 -0
- package/runner/lib/contract.mjs +1122 -0
- package/runner/lib/wire-proxy.mjs +200 -0
- package/runner/personas/claude/kai-asker.md +69 -0
- package/runner/personas/claude/kai-doc-explorer.md +130 -0
- package/runner/personas/claude/kai-documentarian.md +205 -0
- package/runner/personas/claude/kai-researcher.md +68 -0
- package/runner/personas/claude/kai-resolution-analyst.md +164 -0
- package/runner/personas/codex/kai-asker.md +68 -0
- package/runner/personas/codex/kai-doc-explorer.md +130 -0
- package/runner/personas/codex/kai-documentarian.md +211 -0
- package/runner/personas/codex/kai-researcher.md +67 -0
- package/runner/personas/codex/kai-resolution-analyst.md +164 -0
- package/runner/tools/ask-user-mcp.mjs +130 -0
- package/runner/tools/todo-mcp.mjs +116 -0
- package/scripts/postinstall.mjs +24 -0
- package/src/api.mjs +141 -0
- package/src/config.mjs +76 -0
- package/src/daemon.mjs +904 -0
- package/src/executor.mjs +156 -0
- package/src/harnesses.mjs +252 -0
- package/src/preview.mjs +337 -0
- package/src/profiles.mjs +250 -0
- package/src/repos.mjs +182 -0
- package/src/service.mjs +162 -0
- package/src/setup.mjs +261 -0
- package/src/workspace.mjs +175 -0
package/src/preview.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/profiles.mjs
ADDED
|
@@ -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
|
+
}
|