@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/README.md +28 -4
- package/bin/kai-bridge.mjs +7 -6
- package/npm-shrinkwrap.json +2196 -0
- package/package.json +15 -7
- package/runner/lib/acp/harnesses.mjs +6 -1
- package/scripts/runtime-smoke.mjs +60 -0
- package/src/daemon.mjs +45 -75
- package/src/executor.mjs +7 -2
- package/src/harness-errors.mjs +8 -0
- package/src/harness-install.mjs +73 -0
- package/src/harnesses.mjs +47 -31
- package/src/preview-ports.mjs +20 -0
- package/src/preview.mjs +40 -10
- package/src/profiles.mjs +11 -7
- package/src/repository-setup.mjs +96 -0
- package/src/service.mjs +1 -1
- package/src/setup.mjs +1 -1
- package/src/workspace.mjs +4 -11
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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.
|
|
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
|
-
"
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
66
|
+
function packageBinary(harness, packageRequire) {
|
|
78
67
|
if (harness === "claude") {
|
|
79
|
-
const pkgDir = join(PKG, "node_modules", "@anthropic-ai");
|
|
80
68
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
148
|
-
return { ok:
|
|
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
|
|
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();
|