@gleapai/kai-bridge 0.6.0 → 0.9.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.6.0",
4
- "description": "Run Gleap Kai Code sessions on your own machine with your own Claude Code / Codex login — and preview your real dev servers from the dashboard or the phone.",
3
+ "version": "0.9.0",
4
+ "description": "Kai Code Bridge runs Kai Code on your computer or server with your own coding subscriptions, local previews, and photo or video verification.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -12,7 +12,9 @@
12
12
  "src",
13
13
  "runner",
14
14
  "scripts/postinstall.mjs",
15
- "README.md"
15
+ "README.md",
16
+ "scripts/runtime-smoke.mjs",
17
+ "npm-shrinkwrap.json"
16
18
  ],
17
19
  "engines": {
18
20
  "node": ">=20"
@@ -20,19 +22,25 @@
20
22
  "scripts": {
21
23
  "test": "node --test 'test/**/*.test.mjs'",
22
24
  "sync-runner": "node scripts/sync-runner.mjs",
23
- "postinstall": "node scripts/postinstall.mjs"
25
+ "postinstall": "node scripts/postinstall.mjs",
26
+ "runtime:check": "node scripts/runtime-smoke.mjs",
27
+ "prepack": "node scripts/prepare-release.mjs",
28
+ "postpack": "node scripts/prepare-release.mjs --clean"
24
29
  },
25
30
  "dependencies": {
26
- "@agentclientprotocol/claude-agent-acp": "0.73.0",
31
+ "@agentclientprotocol/claude-agent-acp": "0.75.1",
27
32
  "@agentclientprotocol/codex-acp": "1.10.0",
28
33
  "@agentclientprotocol/sdk": "1.4.0",
29
34
  "@playwright/mcp": "^0.0.79",
30
35
  "@sockudo/client": "^2.0.0",
31
36
  "chokidar": "^4.0.1",
32
- "yaml": "^2.9.0"
37
+ "ws": "^8.21.3",
38
+ "yaml": "^2.9.0",
39
+ "@openai/codex": "0.153.4",
40
+ "@anthropic-ai/claude-agent-sdk": "0.3.266"
33
41
  },
34
42
  "overrides": {
35
- "@anthropic-ai/claude-agent-sdk": "0.3.258"
43
+ "@anthropic-ai/claude-agent-sdk": "$@anthropic-ai/claude-agent-sdk"
36
44
  },
37
45
  "exports": {
38
46
  ".": "./src/daemon.mjs",
@@ -339,7 +339,7 @@ export const HARNESSES = {
339
339
  if (!ctx.byoLogin) env.CODEX_API_KEY = env.OPENAI_API_KEY;
340
340
  // MCP servers + disabled/gated tools via config.toml (see
341
341
  // buildCodexConfigToml) — never via session/new for codex.
342
- writeFileSync(join(ctx.configDir, "config.toml"), buildCodexConfigToml(ctx.mcpServers));
342
+ if (process.env.KAI_HOSTED !== '1') writeFileSync(join(ctx.configDir, "config.toml"), buildCodexConfigToml(ctx.mcpServers));
343
343
  env.NO_BROWSER = "1";
344
344
  env.INITIAL_AGENT_MODE = ctx.isPlanMode ? "read-only" : "agent-full-access";
345
345
  // Session config merged by the adapter: model + effort + our
@@ -349,6 +349,11 @@ export const HARNESSES = {
349
349
  ...(ctx.effort ? { model_reasoning_effort: ctx.effort } : {}),
350
350
  ...(ctx.instructionsPath ? { model_instructions_file: ctx.instructionsPath } : {}),
351
351
  ...(ctx.maxContextTokens ? { model_context_window: ctx.maxContextTokens } : {}),
352
+ ...(process.env.KAI_HOSTED === '1' ? { features: { apps: false, collaboration_modes: true },
353
+ mcp_servers: Object.fromEntries((ctx.mcpServers || []).filter(s => s && (s.url || s.command)).map(s => [sanitizeMcpKey(s.name || s.id), {
354
+ ...(s.transport === 'http' ? { url: s.url, http_headers: s.headers || {} } : { command: s.command, args: s.args || [], env: s.env || {} }),
355
+ startup_timeout_sec: 60, tool_timeout_sec: 60, default_tools_approval_mode: 'approve', disabled_tools: [...(s.disabledTools || []), ...(s.gatedTools || [])],
356
+ }])) } : {}),
352
357
  };
353
358
  env.CODEX_CONFIG = JSON.stringify(config);
354
359
  return env;
@@ -0,0 +1,60 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, basename } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ import { harnessBinary, harnessAcpCommand } from '../src/harnesses.mjs';
8
+ import { ensureClaudeAcpPatched } from '../src/acp-patch.mjs';
9
+
10
+ const exec = promisify(execFile);
11
+ const home = mkdtempSync(join(tmpdir(), 'kai-runtime-check-'));
12
+ // No real login, API key, project, device token or user settings in a smoke test.
13
+ const env = { PATH: process.env.PATH, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, 'codex'), CLAUDE_CONFIG_DIR: join(home, 'claude'), DISABLE_AUTOUPDATER: '1', CI: '1', KAI_RUNTIME_UPDATE_CHECK: '1' };
14
+ mkdirSync(env.CODEX_HOME); mkdirSync(env.CLAUDE_CONFIG_DIR);
15
+
16
+ async function initialize(command, args, params) {
17
+ const child = spawn(command, args, { env, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
18
+ const lines = createInterface({ input: child.stdout });
19
+ let diagnostic = '';
20
+ child.stdin.on('error', () => {}); child.stderr.on('data', data => { diagnostic = (diagnostic + data).slice(-1500); });
21
+ let killTimer;
22
+ try {
23
+ await new Promise((resolve, reject) => {
24
+ const timer = setTimeout(() => reject(new Error('Runtime protocol initialization timed out.')), 12_000);
25
+ const finish = error => { clearTimeout(timer); error ? reject(error) : resolve(); };
26
+ child.once('error', finish);
27
+ child.once('exit', (code, signal) => finish(new Error(`${basename(command)} exited before initialization (${code ?? signal}): ${diagnostic}`)));
28
+ lines.on('line', line => {
29
+ let message; try { message = JSON.parse(line); } catch { return; }
30
+ if (message.id === 1) finish(message.error || !message.result ? new Error('Runtime protocol initialization failed.') : null);
31
+ });
32
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }) + '\n');
33
+ });
34
+ } finally {
35
+ lines.close();
36
+ const kill = signal => { try { process.platform === 'win32' ? child.kill(signal) : process.kill(-child.pid, signal); } catch {} };
37
+ kill('SIGTERM');
38
+ await new Promise(resolve => { killTimer = setTimeout(() => { kill('SIGKILL'); resolve(); }, 1500); child.once('close', () => { clearTimeout(killTimer); resolve(); }); });
39
+ }
40
+ }
41
+
42
+ try {
43
+ ensureClaudeAcpPatched();
44
+ for (const harness of ['claude', 'codex']) {
45
+ const binary = harnessBinary(harness);
46
+ if (!binary) throw new Error(`${harness} executable is missing.`);
47
+ const { stdout } = await exec(binary, ['--version'], { env, timeout: 12_000, maxBuffer: 64 * 1024 });
48
+ if (!/\d+\.\d+\.\d+/.test(stdout)) throw new Error(`${harness} version check failed.`);
49
+ console.log(`${harness}: ${stdout.trim()}`);
50
+ env[harness === 'claude' ? 'CLAUDE_CODE_EXECUTABLE' : 'CODEX_PATH'] = binary;
51
+ }
52
+ await initialize(harnessBinary('codex'), ['app-server'], { clientInfo: { name: 'kai-runtime-check', version: '1.0.0' }, capabilities: {} });
53
+ for (const harness of ['claude', 'codex']) {
54
+ const adapter = harnessAcpCommand(harness);
55
+ await initialize(adapter.cmd, adapter.args, { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 'kai-runtime-check', version: '1.0.0' } });
56
+ }
57
+ // Imports catch packaging omissions and syntax/dependency failures before boot.
58
+ await import('../src/daemon.mjs'); await import('../src/harness-install.mjs');
59
+ console.log('Native CLIs, Codex app-server and both ACP adapters passed isolated startup checks.');
60
+ } finally { rmSync(home, { recursive: true, force: true }); }
package/src/daemon.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { cloneRepository, locateRepository } from './repository-setup.mjs';
1
2
  // The always-on part: one outbound realtime subscription for commands,
2
3
  // REST for everything the device reports. Idles at ~0 CPU; reconnects
3
4
  // with backoff; keeps the machine awake only while a turn runs.
@@ -1452,6 +1453,7 @@ export class BridgeDaemon {
1452
1453
  outcome = {
1453
1454
  status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
1454
1455
  exitCode: res.code,
1456
+ ...(res.errorCode ? { errorCode: res.errorCode } : {}),
1455
1457
  result: verify?.redact?.size ? redactDeep(res.result, verify.redact) : res.result,
1456
1458
  // The runner's own failure text (e.g. the engine's usage-limit
1457
1459
  // message) — the Server prefers this over its generic fallback.
@@ -2393,65 +2395,27 @@ export class BridgeDaemon {
2393
2395
  * unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
2394
2396
  * `GIT_ASKPASS=echo`), stderr captured for the error, bounded by
2395
2397
  * `timeoutMs` (default 3 min). Resolves the checkout path; throws an Error
2396
- * carrying `.target` and the stderr tail. As a command handler
2398
+ * carrying `.target` and a diagnostic message. As a command handler
2397
2399
  * (`bridge.repo.clone`) it acks ok/error instead of throwing.
2398
2400
  */
2399
- async cloneRepo({ commandId, remote, name, timeoutMs = CLONE_TIMEOUT_MS }) {
2400
- // Prefer the directory the machine's repos already live in (e.g.
2401
- // ~/Documents/Gleap), not the bare scan root that discovered them
2402
- // (~/Documents) — "Clone here" should land next to the other checkouts.
2401
+ async cloneRepo({ commandId, remote, name, repoKey, timeoutMs = CLONE_TIMEOUT_MS }) {
2403
2402
  const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
2404
- const fail = async (err) => {
2403
+ try {
2404
+ const target = await cloneRepository({ remote, name, root, repoKey, timeoutMs });
2405
+ await this.scanRepos();
2406
+ // Ack only after the Server has the new inventory: recovery checks
2407
+ // readiness before automatically retrying the blocked session.
2408
+ await this.hello();
2409
+ if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
2410
+ return target;
2411
+ } catch (err) {
2412
+ if (err.target) err.cloneCommand = buildCloneCommand({ remote, target: err.target });
2405
2413
  if (commandId) {
2406
2414
  await this.api.commandAck(commandId, { ok: false, error: err.message, ...(err.cloneCommand ? { cloneCommand: err.cloneCommand } : {}) }).catch(() => {});
2407
2415
  return null;
2408
2416
  }
2409
2417
  throw err;
2410
- };
2411
- if (!root) return fail(new Error("No scan root to clone into — add one with `kai-bridge repo roots add <dir>`."));
2412
- const target = join(root, String(name || "").replace(/[^\w.-]/g, "-") || "repo");
2413
- if (existsSync(target)) {
2414
- const err = new Error(`${target} already exists — remove it or point Gleap at it (Locate…).`);
2415
- err.target = target;
2416
- return fail(err);
2417
- }
2418
- try {
2419
- await new Promise((resolve, reject) => {
2420
- const p = spawn("git", ["clone", remote, target], {
2421
- stdio: ["ignore", "ignore", "pipe"],
2422
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "echo", SSH_ASKPASS: "echo", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes" },
2423
- });
2424
- let stderr = "";
2425
- p.stderr.on("data", (d) => {
2426
- stderr = `${stderr}${d}`.slice(-4000);
2427
- });
2428
- const timer = setTimeout(() => {
2429
- try {
2430
- p.kill("SIGTERM");
2431
- } catch {}
2432
- reject(Object.assign(new Error(`git clone timed out after ${Math.round(timeoutMs / 60_000)} minutes`), { target, stderr }));
2433
- }, timeoutMs);
2434
- p.on("close", (code) => {
2435
- clearTimeout(timer);
2436
- if (code === 0) return resolve();
2437
- const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" · ").slice(0, 300);
2438
- reject(Object.assign(new Error(`git clone exited ${code}${tail ? ` — ${tail}` : ""}`), { target, stderr, code }));
2439
- });
2440
- p.on("error", (err) => {
2441
- clearTimeout(timer);
2442
- reject(Object.assign(err, { target }));
2443
- });
2444
- });
2445
- } catch (err) {
2446
- this.log("error", "repo.clone.failed", { remote, target, error: err.message });
2447
- rmSync(target, { recursive: true, force: true });
2448
- err.cloneCommand = buildCloneCommand({ remote, target });
2449
- return fail(err);
2450
2418
  }
2451
- await this.scanRepos();
2452
- await this.hello().catch(() => {});
2453
- if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
2454
- return target;
2455
2419
  }
2456
2420
 
2457
2421
  /**
@@ -2466,11 +2430,7 @@ export class BridgeDaemon {
2466
2430
  */
2467
2431
  async locateRepo({ commandId, repoKey, path: rawPath }) {
2468
2432
  try {
2469
- const target = resolvePath(String(rawPath || "").replace(/^~(?=$|\/)/, homedir()));
2470
- if (!existsSync(target)) throw new Error(`${target} does not exist on this machine.`);
2471
- if (repoKey && !existsSync(join(target, ".git"))) {
2472
- throw new Error(`${target} is not a git checkout.`);
2473
- }
2433
+ const target = locateRepository(rawPath, repoKey);
2474
2434
  const root = repoKey ? dirname(target) : target;
2475
2435
  this.config.roots = [...new Set([...(this.config.roots || []), root])];
2476
2436
  if (repoKey) {
@@ -2499,10 +2459,10 @@ export class BridgeDaemon {
2499
2459
  }
2500
2460
  }
2501
2461
 
2502
- async harnessInstall({ commandId, harness }) {
2462
+ async harnessInstall({ commandId, harness, update = false }) {
2503
2463
  const lines = [];
2504
2464
  try {
2505
- const res = await installHarness(harness, { kaiHome: this.kaiHome, onLog: (l) => { lines.push(l); this.log("info", "harness.install", { harness, line: l }); } });
2465
+ const res = await installHarness(harness, { kaiHome: this.kaiHome, update, onLog: (l) => { lines.push(l); this.log("info", "harness.install", { harness, line: l }); } });
2506
2466
  await this.hello();
2507
2467
  if (commandId) await this.api.commandAck(commandId, { ok: res.ok, version: res.version, log: lines });
2508
2468
  } catch (err) {
@@ -2512,24 +2472,34 @@ export class BridgeDaemon {
2512
2472
  }
2513
2473
 
2514
2474
  async profileLogin({ commandId, profileId }) {
2515
- const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === profileId);
2516
- if (!profile) throw new Error(`Unknown profile ${profileId}`);
2517
- if (profile.kind === "managed") createManagedProfile(profile.harness, profile.id, this.kaiHome);
2518
- // Logins are interactive: open a terminal on this machine running the
2519
- // harness's own login under the profile's config dir, and re-announce
2520
- // the profile state once it exits.
2521
- const child = openLoginTerminal(profile.harness, profile.configDir);
2522
- if (!child) throw new Error(`Could not open a terminal for ${profile.harness} login on this device.`);
2523
- child.unref?.();
2524
- if (commandId) await this.api.commandAck(commandId, { ok: true, opened: "terminal" });
2525
- const poll = setInterval(() => {
2526
- this.hello().catch(() => {});
2527
- // First sign-in for this harness: learn its models as soon as the
2528
- // login lands (later refreshes ride the slow timer).
2529
- if (!this.modelsByHarness.has(profile.harness)) void this.refreshHarnessModels();
2530
- }, 15_000);
2531
- poll.unref?.();
2532
- setTimeout(() => clearInterval(poll), 10 * 60_000).unref?.();
2475
+ try {
2476
+ const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === profileId);
2477
+ if (!profile) throw new Error(`Unknown profile ${profileId}`);
2478
+ if (profile.kind === "managed") createManagedProfile(profile.harness, profile.id, this.kaiHome);
2479
+ // Logins are interactive: open a terminal on this machine running the
2480
+ // harness's own login under the profile's config dir, and re-announce
2481
+ // the profile state once it exits.
2482
+ await installHarness(profile.harness, { kaiHome: this.kaiHome });
2483
+ const child = openLoginTerminal(profile.harness, profile.configDir, this.kaiHome);
2484
+ if (child) await new Promise((resolve, reject) => {
2485
+ child.once('spawn', resolve);
2486
+ child.once('error', reject);
2487
+ });
2488
+ child?.unref?.();
2489
+ const command = `kai-bridge profile login '${String(profile.id).replace(/'/g, "'\\''")}'${profile.harness === 'codex' ? ' --device-auth' : ''}`;
2490
+ if (commandId) await this.api.commandAck(commandId, { ok: true, opened: child ? "terminal" : "ssh", ...(!child ? { command } : {}) });
2491
+ const poll = setInterval(() => {
2492
+ this.hello().catch(() => {});
2493
+ // First sign-in for this harness: learn its models as soon as the
2494
+ // login lands (later refreshes ride the slow timer).
2495
+ if (!this.modelsByHarness.has(profile.harness)) void this.refreshHarnessModels();
2496
+ }, 15_000);
2497
+ poll.unref?.();
2498
+ setTimeout(() => clearInterval(poll), 10 * 60_000).unref?.();
2499
+ } catch (err) {
2500
+ if (commandId) await this.api.commandAck(commandId, { ok: false, error: err.message }).catch(() => {});
2501
+ else throw err;
2502
+ }
2533
2503
  }
2534
2504
  }
2535
2505
 
package/src/executor.mjs CHANGED
@@ -13,8 +13,9 @@ import { delimiter, dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
15
15
  import { ambientConfigDir, managedConfigDir } from "./profiles.mjs";
16
- import { harnessAcpCommand } from "./harnesses.mjs";
16
+ import { harnessAcpCommand, harnessBinary } from "./harnesses.mjs";
17
17
  import { KAI_HOME } from "./config.mjs";
18
+ import { classifyHarnessFailure } from './harness-errors.mjs';
18
19
 
19
20
  const RUNNER = join(dirname(fileURLToPath(import.meta.url)), "..", "runner", "acp-runner.mjs");
20
21
  const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v), "utf8").toString("base64");
@@ -77,6 +78,10 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
77
78
  for (const [k, v] of Object.entries(extraEnv || {})) if (v != null && v !== "") env[k] = String(v);
78
79
  // Never leak the Gleap device token into the harness.
79
80
  delete env.KAI_DEVICE_TOKEN;
81
+ // Login, version probes and coding must resolve the same release's binaries.
82
+ const native = harnessBinary(profile.harness, kaiHome);
83
+ if (native && profile.harness === 'claude') { env.CLAUDE_CODE_EXECUTABLE = native; env.DISABLE_AUTOUPDATER = '1'; }
84
+ if (native && profile.harness === 'codex') env.CODEX_PATH = native;
80
85
  // The daemon's own node first on PATH for the runner, the harness and
81
86
  // every shell the agent opens. Under launchd PATH is frozen at install
82
87
  // time and a stale /usr/local/bin/node (v20 here) beat the nvm node the
@@ -190,7 +195,7 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
190
195
  signal?.addEventListener("abort", onAbort, { once: true });
191
196
  child.on("close", (code) => {
192
197
  signal?.removeEventListener("abort", onAbort);
193
- resolve({ code, result, rateLimited, lastError });
198
+ resolve({ code, result, rateLimited, lastError, errorCode: classifyHarnessFailure(lastError || '') });
194
199
  });
195
200
  });
196
201
  }
@@ -0,0 +1,8 @@
1
+ // Only harness authentication failures invalidate a login. An application's
2
+ // HTTP 401 during validation, a quota limit, or an outage must not sign it out.
3
+ export function classifyHarnessFailure(message = '') {
4
+ if (/rate.?limit|429|usage limit|out of (extra )?usage/i.test(message)) return 'rate_limited';
5
+ if (/refresh_token_(reused|expired|invalidated)|not logged in|login (has )?expired|login required|authentication[_ ](?:error|required)|oauth.{0,30}(expired|invalid)|model provider rejected.{0,30}401/i.test(message)) return 'authentication_required';
6
+ if (/service unavailable|provider.{0,30}(unavailable|outage)|HTTP 50[234]|ECONNRESET|ETIMEDOUT/i.test(message)) return 'provider_unavailable';
7
+ return null;
8
+ }
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ const exec = promisify(execFile);
8
+ export const HARNESS_PACKAGES = { codex: '@openai/codex', claude: '@anthropic-ai/claude-agent-sdk' };
9
+ const rootFor = (home, harness) => join(home, 'harnesses', harness);
10
+
11
+ export function managedHarnessRoot(home, harness) {
12
+ try {
13
+ const { active } = JSON.parse(readFileSync(join(rootFor(home, harness), 'active.json'), 'utf8'));
14
+ if (!/^[a-f0-9-]{36}$/.test(active)) return null;
15
+ const root = join(rootFor(home, harness), 'releases', active);
16
+ return existsSync(join(root, 'package.json')) ? root : null;
17
+ } catch { return null; }
18
+ }
19
+
20
+ async function installPackage(destination, spec) {
21
+ const npmCli = [process.env.npm_execpath, join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), join(dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')]
22
+ .find(p => p?.endsWith('npm-cli.js') && existsSync(p));
23
+ if (!npmCli && process.platform === 'win32') throw new Error('npm is missing. Install Node.js with npm, then retry.');
24
+ const args = ['install', '--prefix', destination, '--ignore-scripts', '--save-exact', '--no-audit', '--no-fund', spec];
25
+ await exec(npmCli ? process.execPath : 'npm', npmCli ? [npmCli, ...args] : args, {
26
+ timeout: 5 * 60_000, maxBuffer: 2 * 1024 * 1024,
27
+ });
28
+ }
29
+
30
+ /** Download into an isolated release, verify it runs, then atomically select it.
31
+ * Existing sessions retain their old binaries; profiles and global CLIs are untouched. */
32
+ export async function installManagedHarness({ harness, kaiHome, version = 'latest', resolveBinary, probeVersion, onLog = () => {}, install = installPackage }) {
33
+ const packageName = HARNESS_PACKAGES[harness];
34
+ if (!packageName || !/^(latest|\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/.test(version)) throw new Error('Choose a valid coding agent version.');
35
+ const root = rootFor(kaiHome, harness);
36
+ mkdirSync(root, { recursive: true });
37
+ const lock = join(root, '.install-lock');
38
+ try { mkdirSync(lock); } catch {
39
+ let abandoned = false;
40
+ try {
41
+ const { pid } = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
42
+ if (Number.isSafeInteger(pid) && pid > 0) {
43
+ try { process.kill(pid, 0); } catch (error) { abandoned = error.code === 'ESRCH'; }
44
+ }
45
+ } catch { abandoned = Date.now() - statSync(lock).mtimeMs > 10 * 60_000; }
46
+ if (!abandoned) throw new Error('An installation is already running for this agent. Try again when it finishes.');
47
+ rmSync(lock, { recursive: true, force: true });
48
+ try { mkdirSync(lock); } catch { throw new Error('Another agent installation started. Try again when it finishes.'); }
49
+ }
50
+ writeFileSync(join(lock, 'owner.json'), JSON.stringify({ pid: process.pid }));
51
+ const release = randomUUID();
52
+ const destination = join(root, 'releases', release);
53
+ const temporary = join(root, `active-${release}.tmp`);
54
+ let activated = false;
55
+ try {
56
+ mkdirSync(destination, { recursive: true });
57
+ writeFileSync(join(destination, 'package.json'), JSON.stringify({ private: true }));
58
+ onLog(`Installing ${harness} ${version}…`);
59
+ await install(destination, `${packageName}@${version}`);
60
+ const binary = resolveBinary(destination);
61
+ const detected = binary && await probeVersion(binary);
62
+ if (!detected) throw new Error('The new coding agent did not start. Your previous version is still selected.');
63
+ writeFileSync(temporary, JSON.stringify({ active: release, version: detected }), { mode: 0o600 });
64
+ renameSync(temporary, join(root, 'active.json'));
65
+ activated = true;
66
+ onLog(`Ready: ${detected}`);
67
+ return { ok: true, version: detected, binary };
68
+ } finally {
69
+ rmSync(temporary, { force: true });
70
+ if (!activated) rmSync(destination, { recursive: true, force: true });
71
+ rmSync(lock, { recursive: true, force: true });
72
+ }
73
+ }
package/src/harnesses.mjs CHANGED
@@ -1,27 +1,16 @@
1
- // Harness registry: which coding agents this device can run, how to find
2
- // their binaries, install/update them, probe their login, and open a
3
- // login.
4
- //
5
- // claude — Claude Code. BUNDLED: the ACP adapter ships the Agent SDK,
6
- // which ships the CLI. Nothing to install; only a login.
7
- // codex — Codex CLI. BUNDLED by codex-acp. Only a login.
8
- // cursor — Cursor Agent CLI. NOT bundled (no npm package); installed by
9
- // the bridge from Cursor's release tarball into
10
- // ~/.kai/harnesses/cursor/<version>/ and called by path — never
11
- // via the vendor's ~/.local/bin/agent symlink (other vendors
12
- // use that name too: Grok's CLI on at least one dev machine).
13
- //
14
- // "Installed" therefore means: claude/codex always; cursor when the
15
- // tarball is present. "Signed in" is per profile (see profiles.mjs).
1
+ // Coding agents: bundled defaults, optional managed updates, and per-profile sign-in.
16
2
 
17
3
  import { execFileSync, spawn } from "node:child_process";
18
4
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
19
5
  import { arch, homedir, platform } from "node:os";
20
6
  import { dirname, join, resolve as resolvePath } from "node:path";
21
7
  import { fileURLToPath } from "node:url";
8
+ import { createRequire } from 'node:module';
9
+ import { managedHarnessRoot, installManagedHarness } from './harness-install.mjs';
22
10
 
23
11
  const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
24
12
  const PKG_BIN = join(PKG, "node_modules", ".bin");
13
+ const require = createRequire(import.meta.url);
25
14
 
26
15
  export const HARNESS_IDS = ["claude", "codex", "cursor"];
27
16
 
@@ -74,24 +63,43 @@ export function cursorDownloadUrl(version = CURSOR_AGENT_VERSION) {
74
63
  }
75
64
 
76
65
  /** Bundled / installed binary for a harness, or null. */
77
- export function harnessBinary(harness, kaiHome = join(homedir(), ".kai")) {
66
+ function packageBinary(harness, packageRequire) {
78
67
  if (harness === "claude") {
79
- const pkgDir = join(PKG, "node_modules", "@anthropic-ai");
80
68
  try {
81
- const plat = readdirSync(pkgDir).find((d) => d.startsWith("claude-agent-sdk-") && d !== "claude-agent-sdk");
82
- if (plat) {
83
- const bin = join(pkgDir, plat, platform() === "win32" ? "claude.exe" : "claude");
84
- if (existsSync(bin)) return bin;
85
- }
69
+ // Resolve from the SDK exactly as the ACP adapter does, including nested
70
+ // npm installs and the correct libc. Directory order is not a platform check.
71
+ const sdkRequire = createRequire(packageRequire.resolve('@anthropic-ai/claude-agent-sdk'));
72
+ const libc = platform() === 'linux' && !process.report.getReport().header.glibcVersionRuntime ? '-musl' : '';
73
+ return sdkRequire.resolve(`@anthropic-ai/claude-agent-sdk-${platform()}-${arch()}${libc}/claude${platform() === 'win32' ? '.exe' : ''}`);
86
74
  } catch {
87
75
  /* not installed */
88
76
  }
89
77
  return null;
90
78
  }
91
79
  if (harness === "codex") {
92
- const bin = join(PKG_BIN, platform() === "win32" ? "codex.cmd" : "codex");
93
- return existsSync(bin) ? bin : null;
80
+ try {
81
+ if (platform() === 'win32') {
82
+ // execFile/ACP cannot execute a JS entrypoint or .cmd shim directly on
83
+ // Windows. Resolve the same pinned package's native executable there.
84
+ const codexRequire = createRequire(packageRequire.resolve('@openai/codex/package.json'));
85
+ const root = dirname(codexRequire.resolve(`@openai/codex-win32-${arch()}/package.json`));
86
+ const triple = `${arch() === 'arm64' ? 'aarch64' : 'x86_64'}-pc-windows-msvc`;
87
+ const binary = join(root, 'vendor', triple, 'bin', 'codex.exe');
88
+ return existsSync(binary) ? binary : null;
89
+ }
90
+ return packageRequire.resolve('@openai/codex/bin/codex.js');
91
+ } catch { return null; }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ export function harnessBinary(harness, kaiHome = process.env.KAI_HOME || join(homedir(), ".kai")) {
97
+ const managed = managedHarnessRoot(kaiHome, harness);
98
+ if (managed) {
99
+ const binary = packageBinary(harness, createRequire(join(managed, "package.json")));
100
+ if (binary && existsSync(binary)) return binary;
94
101
  }
102
+ if (harness === "claude" || harness === "codex") return packageBinary(harness, require);
95
103
  if (harness === "cursor") {
96
104
  const bin = join(CURSOR_CURRENT(kaiHome), "dist-package", platform() === "win32" ? "cursor-agent.exe" : "cursor-agent");
97
105
  return existsSync(bin) ? bin : null;
@@ -135,18 +143,22 @@ export function describeHarnesses(kaiHome) {
135
143
  }
136
144
 
137
145
  /**
138
- * Install (or update) a harness. Bundled ones are no-ops. Cursor:
146
+ * Install (or update) a coding agent. Cursor:
139
147
  * download the release tarball for this platform into
140
148
  * ~/.kai/harnesses/cursor/<version>/ and point `current` at it.
141
149
  * `onLog(line)` receives progress; resolves `{ ok, version, binary }`.
142
150
  */
143
- export async function installHarness(harness, { kaiHome, onLog = () => {}, version = CURSOR_AGENT_VERSION } = {}) {
151
+ export async function installHarness(harness, { kaiHome = process.env.KAI_HOME || join(homedir(), ".kai"), onLog = () => {}, version, update = false } = {}) {
144
152
  if (!HARNESS_IDS.includes(harness)) throw new Error(`unknown harness ${harness}`);
145
153
  if (HARNESS_INFO[harness].bundled) {
146
154
  const bin = harnessBinary(harness, kaiHome);
147
- onLog(`${HARNESS_INFO[harness].label} is bundled with the bridge (${bin ? versionOf(bin) : "missing — run npm install"})`);
148
- return { ok: !!bin, version: bin ? versionOf(bin) : null, binary: bin };
155
+ const currentVersion = bin ? versionOf(bin) : null;
156
+ if (!update && !version && currentVersion) return { ok: true, version: currentVersion, binary: bin };
157
+ return installManagedHarness({ harness, kaiHome, version: version || 'latest', onLog,
158
+ resolveBinary: root => packageBinary(harness, createRequire(join(root, 'package.json'))), probeVersion: versionOf });
149
159
  }
160
+ version ||= CURSOR_AGENT_VERSION;
161
+ if (!/^[0-9][a-zA-Z0-9.-]*$/.test(version)) throw new Error("Invalid Cursor version.");
150
162
  const root = CURSOR_ROOT(kaiHome);
151
163
  const target = join(root, version);
152
164
  const tmp = join(root, `.tmp-${version}-${process.pid}`);
@@ -186,7 +198,11 @@ export async function installHarness(harness, { kaiHome, onLog = () => {}, versi
186
198
  }
187
199
 
188
200
  /** Interactive login command for a harness under a profile's config dir. */
189
- export function harnessLoginCommand(harness, configDir, kaiHome) {
201
+ export function isHeadless(env = process.env, os = platform()) {
202
+ return !!(env.SSH_CONNECTION || env.SSH_TTY || (os === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY));
203
+ }
204
+
205
+ export function harnessLoginCommand(harness, configDir, kaiHome, { deviceAuth = isHeadless() } = {}) {
190
206
  const bin = harnessBinary(harness, kaiHome);
191
207
  if (!bin) return null;
192
208
  if (harness === "claude") {
@@ -194,9 +210,9 @@ export function harnessLoginCommand(harness, configDir, kaiHome) {
194
210
  // Ambient login must land in the keychain (env untouched) so the
195
211
  // equally-untouched probe and turns find it; managed dirs are exported.
196
212
  const env = resolvePath(configDir) !== resolvePath(claudeDefault) ? { CLAUDE_CONFIG_DIR: configDir } : {};
197
- return { cmd: bin, args: ["login"], env };
213
+ return { cmd: bin, args: ["auth", "login"], env };
198
214
  }
199
- if (harness === "codex") return { cmd: bin, args: ["login"], env: { CODEX_HOME: configDir } };
215
+ if (harness === "codex") return { cmd: bin, args: ["login", ...(deviceAuth ? ["--device-auth"] : [])], env: { CODEX_HOME: configDir } };
200
216
  return { cmd: bin, args: ["login"], env: {} };
201
217
  }
202
218
 
@@ -0,0 +1,20 @@
1
+ // Reserve before boot: two sessions can otherwise choose the same free port.
2
+ export class PreviewPortPool {
3
+ constructor() { this.owners = new Map(); this.tail = Promise.resolve(); }
4
+ reserve(owner, preferred, pinned, listening) {
5
+ const operation = this.tail.then(async () => {
6
+ const current = [...this.owners].find(([, who]) => who === owner);
7
+ if (current) return current[0];
8
+ const first = Number.isInteger(preferred) && preferred >= 1024 && preferred <= 65535 ? preferred : 43000;
9
+ for (let i = 0; i < (pinned ? 1 : 10000); i++) {
10
+ const port = 1024 + ((first - 1024 + i) % (65536 - 1024));
11
+ if (this.owners.has(port) || await listening(port)) continue;
12
+ this.owners.set(port, owner); return port;
13
+ }
14
+ throw new Error(pinned ? `Port ${first} is already reserved. Use a port variable in .gleap/dev.yaml to run concurrent previews.` : 'No preview ports are available. Stop an unused preview and retry.');
15
+ });
16
+ this.tail = operation.catch(() => {}); return operation;
17
+ }
18
+ releaseSession(sessionId) { for (const [port, owner] of this.owners) if (owner.startsWith(`${sessionId}/`)) this.owners.delete(port); }
19
+ }
20
+ export const previewPorts = new PreviewPortPool();