@gleapai/kai-bridge 0.7.0 → 0.9.1
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 +29 -4
- package/bin/kai-bridge.mjs +19 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -7
- package/scripts/runtime-smoke.mjs +1 -1
- package/src/api.mjs +8 -0
- package/src/daemon.mjs +142 -93
- package/src/executor.mjs +5 -16
- package/src/git-auth.mjs +38 -0
- package/src/harness-errors.mjs +8 -0
- package/src/harness-install.mjs +73 -0
- package/src/harnesses.mjs +30 -25
- package/src/{hosted-ports.mjs → preview-ports.mjs} +2 -2
- package/src/preview.mjs +14 -20
- package/src/profiles.mjs +6 -5
- package/src/ps.mjs +152 -0
- package/src/repository-setup.mjs +2 -2
- package/src/service.mjs +1 -1
- package/src/setup.mjs +1 -1
- package/src/workspace.mjs +92 -28
- package/fly/Dockerfile +0 -11
- package/fly/UPDATES.md +0 -76
- package/fly/codex-auth.mjs +0 -5
- package/fly/entrypoint.sh +0 -12
- package/fly/login-codex.sh +0 -9
- package/fly/runtime-cli.mjs +0 -15
- package/fly/runtime-cli.sh +0 -10
- package/fly/runtime-launch.mjs +0 -8
- package/fly/runtime-update.mjs +0 -137
- package/fly/start.mjs +0 -7
- package/fly/supervisord.conf +0 -45
- package/src/codex-broker-client.mjs +0 -16
- package/src/codex-broker.mjs +0 -85
- package/src/hosted-auth.mjs +0 -42
- package/src/hosted-git-credential.mjs +0 -24
- package/src/hosted-git.mjs +0 -33
- package/src/hosted-resources.mjs +0 -35
- package/src/hosted-tunnel.mjs +0 -76
- package/src/hosted.mjs +0 -86
|
@@ -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,18 +1,4 @@
|
|
|
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";
|
|
@@ -20,6 +6,7 @@ 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";
|
|
22
8
|
import { createRequire } from 'node:module';
|
|
9
|
+
import { managedHarnessRoot, installManagedHarness } from './harness-install.mjs';
|
|
23
10
|
|
|
24
11
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
12
|
const PKG_BIN = join(PKG, "node_modules", ".bin");
|
|
@@ -76,12 +63,12 @@ export function cursorDownloadUrl(version = CURSOR_AGENT_VERSION) {
|
|
|
76
63
|
}
|
|
77
64
|
|
|
78
65
|
/** Bundled / installed binary for a harness, or null. */
|
|
79
|
-
|
|
66
|
+
function packageBinary(harness, packageRequire) {
|
|
80
67
|
if (harness === "claude") {
|
|
81
68
|
try {
|
|
82
69
|
// Resolve from the SDK exactly as the ACP adapter does, including nested
|
|
83
70
|
// npm installs and the correct libc. Directory order is not a platform check.
|
|
84
|
-
const sdkRequire = createRequire(
|
|
71
|
+
const sdkRequire = createRequire(packageRequire.resolve('@anthropic-ai/claude-agent-sdk'));
|
|
85
72
|
const libc = platform() === 'linux' && !process.report.getReport().header.glibcVersionRuntime ? '-musl' : '';
|
|
86
73
|
return sdkRequire.resolve(`@anthropic-ai/claude-agent-sdk-${platform()}-${arch()}${libc}/claude${platform() === 'win32' ? '.exe' : ''}`);
|
|
87
74
|
} catch {
|
|
@@ -94,15 +81,25 @@ export function harnessBinary(harness, kaiHome = join(homedir(), ".kai")) {
|
|
|
94
81
|
if (platform() === 'win32') {
|
|
95
82
|
// execFile/ACP cannot execute a JS entrypoint or .cmd shim directly on
|
|
96
83
|
// Windows. Resolve the same pinned package's native executable there.
|
|
97
|
-
const codexRequire = createRequire(
|
|
84
|
+
const codexRequire = createRequire(packageRequire.resolve('@openai/codex/package.json'));
|
|
98
85
|
const root = dirname(codexRequire.resolve(`@openai/codex-win32-${arch()}/package.json`));
|
|
99
86
|
const triple = `${arch() === 'arm64' ? 'aarch64' : 'x86_64'}-pc-windows-msvc`;
|
|
100
87
|
const binary = join(root, 'vendor', triple, 'bin', 'codex.exe');
|
|
101
88
|
return existsSync(binary) ? binary : null;
|
|
102
89
|
}
|
|
103
|
-
return
|
|
90
|
+
return packageRequire.resolve('@openai/codex/bin/codex.js');
|
|
104
91
|
} catch { return null; }
|
|
105
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;
|
|
101
|
+
}
|
|
102
|
+
if (harness === "claude" || harness === "codex") return packageBinary(harness, require);
|
|
106
103
|
if (harness === "cursor") {
|
|
107
104
|
const bin = join(CURSOR_CURRENT(kaiHome), "dist-package", platform() === "win32" ? "cursor-agent.exe" : "cursor-agent");
|
|
108
105
|
return existsSync(bin) ? bin : null;
|
|
@@ -146,18 +143,22 @@ export function describeHarnesses(kaiHome) {
|
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
/**
|
|
149
|
-
* Install (or update) a
|
|
146
|
+
* Install (or update) a coding agent. Cursor:
|
|
150
147
|
* download the release tarball for this platform into
|
|
151
148
|
* ~/.kai/harnesses/cursor/<version>/ and point `current` at it.
|
|
152
149
|
* `onLog(line)` receives progress; resolves `{ ok, version, binary }`.
|
|
153
150
|
*/
|
|
154
|
-
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 } = {}) {
|
|
155
152
|
if (!HARNESS_IDS.includes(harness)) throw new Error(`unknown harness ${harness}`);
|
|
156
153
|
if (HARNESS_INFO[harness].bundled) {
|
|
157
154
|
const bin = harnessBinary(harness, kaiHome);
|
|
158
|
-
|
|
159
|
-
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 });
|
|
160
159
|
}
|
|
160
|
+
version ||= CURSOR_AGENT_VERSION;
|
|
161
|
+
if (!/^[0-9][a-zA-Z0-9.-]*$/.test(version)) throw new Error("Invalid Cursor version.");
|
|
161
162
|
const root = CURSOR_ROOT(kaiHome);
|
|
162
163
|
const target = join(root, version);
|
|
163
164
|
const tmp = join(root, `.tmp-${version}-${process.pid}`);
|
|
@@ -197,7 +198,11 @@ export async function installHarness(harness, { kaiHome, onLog = () => {}, versi
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
/** Interactive login command for a harness under a profile's config dir. */
|
|
200
|
-
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() } = {}) {
|
|
201
206
|
const bin = harnessBinary(harness, kaiHome);
|
|
202
207
|
if (!bin) return null;
|
|
203
208
|
if (harness === "claude") {
|
|
@@ -207,7 +212,7 @@ export function harnessLoginCommand(harness, configDir, kaiHome) {
|
|
|
207
212
|
const env = resolvePath(configDir) !== resolvePath(claudeDefault) ? { CLAUDE_CONFIG_DIR: configDir } : {};
|
|
208
213
|
return { cmd: bin, args: ["auth", "login"], env };
|
|
209
214
|
}
|
|
210
|
-
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 } };
|
|
211
216
|
return { cmd: bin, args: ["login"], env: {} };
|
|
212
217
|
}
|
|
213
218
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Reserve before boot: two sessions can otherwise choose the same free port.
|
|
2
|
-
export class
|
|
2
|
+
export class PreviewPortPool {
|
|
3
3
|
constructor() { this.owners = new Map(); this.tail = Promise.resolve(); }
|
|
4
4
|
reserve(owner, preferred, pinned, listening) {
|
|
5
5
|
const operation = this.tail.then(async () => {
|
|
@@ -17,4 +17,4 @@ export class HostedPortPool {
|
|
|
17
17
|
}
|
|
18
18
|
releaseSession(sessionId) { for (const [port, owner] of this.owners) if (owner.startsWith(`${sessionId}/`)) this.owners.delete(port); }
|
|
19
19
|
}
|
|
20
|
-
export const
|
|
20
|
+
export const previewPorts = new PreviewPortPool();
|
package/src/preview.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { boundHostedProcess } from './hosted-resources.mjs';
|
|
1
|
+
import { previewPorts } from './preview-ports.mjs';
|
|
3
2
|
// Preview tier A — run the app's real dev servers next to the session.
|
|
4
3
|
//
|
|
5
4
|
// Each repo may commit a `.gleap/dev.yaml`:
|
|
@@ -389,7 +388,6 @@ function lockfileHash(cwd) {
|
|
|
389
388
|
|
|
390
389
|
/** Runs `cmd` through the user's login shell (darwin) or the platform shell, capturing output into `fd`. */
|
|
391
390
|
function spawnShell(cmd, { cwd, env, fd, detached = false }) {
|
|
392
|
-
if (process.env.KAI_HOSTED === '1') { env = { ...env, NODE_OPTIONS: '--max-old-space-size=1024' }; detached = true; }
|
|
393
391
|
if (process.platform !== "win32") cmd = withDaemonNode(cmd);
|
|
394
392
|
if (process.platform === "darwin") {
|
|
395
393
|
// Under launchd the daemon's PATH is frozen at install time
|
|
@@ -400,7 +398,6 @@ function spawnShell(cmd, { cwd, env, fd, detached = false }) {
|
|
|
400
398
|
// Windows has no process groups to kill and `detached` would open a
|
|
401
399
|
// console window — taskkill /T does the tree.
|
|
402
400
|
const child = spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: process.platform !== "win32" && detached, windowsHide: true });
|
|
403
|
-
boundHostedProcess(child, { limitMb: 1536 });
|
|
404
401
|
return child;
|
|
405
402
|
}
|
|
406
403
|
|
|
@@ -417,14 +414,13 @@ export class ServiceRunner {
|
|
|
417
414
|
* `describeListener(port)` (ports.mjs by default) decides adoption;
|
|
418
415
|
* `onServiceExit({ name, code, repoRoot, repoKey, logPath, error, errorCode, detail })`
|
|
419
416
|
* fires when a service that had become ready dies on its own;
|
|
420
|
-
* `onProcess(name, pid, "add" | "remove")` lets the daemon persist pids.
|
|
417
|
+
* `onProcess(name, pid, "add" | "remove", { port })` lets the daemon persist pids.
|
|
421
418
|
*/
|
|
422
|
-
constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir()
|
|
419
|
+
constructor({ kaiHome, sessionId, log = () => {}, onStatus = () => {}, preferredPort = null, describeListener = null, settleMs = DEFAULT_SETTLE_MS, onServiceExit = () => {}, onProcess = () => {}, home = homedir() } = {}) {
|
|
423
420
|
if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
|
|
424
421
|
this.kaiHome = kaiHome;
|
|
425
422
|
this.sessionId = sessionId;
|
|
426
|
-
this.
|
|
427
|
-
this.publicUrls = {};
|
|
423
|
+
this.serviceUrls = {};
|
|
428
424
|
this.log = log;
|
|
429
425
|
this.onStatus = onStatus;
|
|
430
426
|
this.preferredPort = typeof preferredPort === "function" ? preferredPort : null;
|
|
@@ -479,13 +475,10 @@ export class ServiceRunner {
|
|
|
479
475
|
}
|
|
480
476
|
if (this.ports[svc.name]) continue;
|
|
481
477
|
const declared = svc.port;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
this.ports[svc.name] = await hostedPorts.reserve(`${this.sessionId}/${repoRoot}/${svc.name}`, declared || await this.preferredPort?.({ repoKey, service: svc.name }), pinned, isPortListening);
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
478
|
+
const owner = `${this.sessionId}/${repoRoot}/${svc.name}`;
|
|
479
|
+
const pinned = !!declared && (mode === 'local' || new RegExp(`(^|[^0-9])${declared}([^0-9]|$)`).test(`${svc.run} ${Object.values(svc.env).join(' ')}`));
|
|
487
480
|
if (declared && !(await isPortListening(declared))) {
|
|
488
|
-
this.ports[svc.name] = declared;
|
|
481
|
+
this.ports[svc.name] = await previewPorts.reserve(owner, declared, pinned, isPortListening);
|
|
489
482
|
continue;
|
|
490
483
|
}
|
|
491
484
|
if (declared) {
|
|
@@ -513,10 +506,11 @@ export class ServiceRunner {
|
|
|
513
506
|
});
|
|
514
507
|
}
|
|
515
508
|
}
|
|
516
|
-
|
|
509
|
+
const preferred = (declared ? await findFreePortNear(declared) : await this.stablePort(repoKey, svc.name)) ?? (await getFreePort());
|
|
510
|
+
this.ports[svc.name] = await previewPorts.reserve(owner, preferred, false, isPortListening);
|
|
517
511
|
}
|
|
518
512
|
for (const svc of services) {
|
|
519
|
-
this.
|
|
513
|
+
this.serviceUrls[svc.name] = `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
|
|
520
514
|
}
|
|
521
515
|
this.registered.set(repoRoot, services);
|
|
522
516
|
this.previewNames ??= new Map();
|
|
@@ -594,7 +588,7 @@ export class ServiceRunner {
|
|
|
594
588
|
const env = {
|
|
595
589
|
...process.env,
|
|
596
590
|
PORT: String(port),
|
|
597
|
-
...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.
|
|
591
|
+
...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.serviceUrls)])),
|
|
598
592
|
KAI_SESSION_ID: String(this.sessionId),
|
|
599
593
|
BROWSER: "none",
|
|
600
594
|
};
|
|
@@ -603,7 +597,7 @@ export class ServiceRunner {
|
|
|
603
597
|
// straight into `command not found` was the #1 preview failure. The
|
|
604
598
|
// install is authoritative: when it fails, the dev command never runs.
|
|
605
599
|
await this.ensureDeps(cwd, { env, fd, logPath, service: name, repoKey });
|
|
606
|
-
const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.
|
|
600
|
+
const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.serviceUrls));
|
|
607
601
|
const child = spawnShell(cmd, { cwd, env, fd, detached: true });
|
|
608
602
|
m.ready = false;
|
|
609
603
|
child.on("exit", (code) => {
|
|
@@ -635,7 +629,7 @@ export class ServiceRunner {
|
|
|
635
629
|
).catch(() => {});
|
|
636
630
|
});
|
|
637
631
|
this.processes.set(name, child);
|
|
638
|
-
this.onProcess(name, child.pid, "add");
|
|
632
|
+
this.onProcess(name, child.pid, "add", { port });
|
|
639
633
|
this.onStatus(`Starting ${name} (${cmd}) on :${port}`);
|
|
640
634
|
// Bail as soon as the process dies (command not found, crash on
|
|
641
635
|
// boot) instead of polling a dead port for the full timeout.
|
|
@@ -865,7 +859,7 @@ export class ServiceRunner {
|
|
|
865
859
|
}
|
|
866
860
|
|
|
867
861
|
stopAll() {
|
|
868
|
-
|
|
862
|
+
previewPorts.releaseSession(this.sessionId);
|
|
869
863
|
for (const [name, child] of this.processes) {
|
|
870
864
|
this.stopping.add(name);
|
|
871
865
|
if (process.platform === "win32") {
|
package/src/profiles.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { execFileSync, spawn } from "node:child_process";
|
|
15
15
|
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
16
|
-
import { HARNESS_IDS as REGISTRY_IDS, harnessBinary, harnessLoginCommand, probeHarnessAuth } from "./harnesses.mjs";
|
|
16
|
+
import { HARNESS_IDS as REGISTRY_IDS, harnessBinary, harnessLoginCommand, isHeadless, probeHarnessAuth } from "./harnesses.mjs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -112,8 +112,8 @@ export function createManagedProfile(harness, profileId, kaiHome) {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
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);
|
|
115
|
+
export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai"), options) {
|
|
116
|
+
const c = harnessLoginCommand(harness, configDir, kaiHome, options);
|
|
117
117
|
if (!c) return null;
|
|
118
118
|
return { cmd: c.cmd, args: c.args, env: { ...process.env, ...c.env } };
|
|
119
119
|
}
|
|
@@ -124,8 +124,9 @@ export function loginCommand(harness, configDir, kaiHome = process.env.KAI_HOME
|
|
|
124
124
|
* DEVICE running the login under the profile's config dir. Returns the
|
|
125
125
|
* spawned process or null when no terminal could be opened.
|
|
126
126
|
*/
|
|
127
|
-
export function openLoginTerminal(harness, configDir) {
|
|
128
|
-
|
|
127
|
+
export function openLoginTerminal(harness, configDir, kaiHome) {
|
|
128
|
+
if (isHeadless()) return null;
|
|
129
|
+
const c = loginCommand(harness, configDir, kaiHome);
|
|
129
130
|
if (!c) return null;
|
|
130
131
|
// Ambient claude must log in with the env untouched so credentials land
|
|
131
132
|
// in the keychain, where the (equally untouched) probe and turns look.
|
package/src/ps.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// `kai-bridge ps` — what this machine is running right now, grouped by
|
|
2
|
+
// Kai Code session. Read-only: it never talks to the daemon, it reads
|
|
3
|
+
// the files the daemon keeps for crash recovery (daemon.mjs):
|
|
4
|
+
//
|
|
5
|
+
// ~/.kai/daemon.lock pid of the running daemon
|
|
6
|
+
// ~/.kai/state/inflight.json turns in progress (+ runner pid, session meta)
|
|
7
|
+
// ~/.kai/state/preview-pids.json dev-server processes (+ port, session meta)
|
|
8
|
+
//
|
|
9
|
+
// Liveness is `kill -0`. Dead pids are LABELLED, never cleaned up — the
|
|
10
|
+
// daemon owns those files (reportInterruptedTurns / adoptPreviewPids).
|
|
11
|
+
// `collectProcessList` is the one source of truth for the text view,
|
|
12
|
+
// `--json`, and any host UI (a menu-bar widget) that wants the same list.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { KAI_HOME } from "./config.mjs";
|
|
18
|
+
import { installedVersionOrNull } from "./selfupdate.mjs";
|
|
19
|
+
|
|
20
|
+
export function pidAlive(pid) {
|
|
21
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// EPERM: the process exists but belongs to someone else — still alive.
|
|
27
|
+
return err?.code === "EPERM";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readJson(path, fallback) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readDaemon(kaiHome, isAlive, version) {
|
|
40
|
+
const lock = join(kaiHome, "daemon.lock");
|
|
41
|
+
let pid = null;
|
|
42
|
+
let startedAt = null;
|
|
43
|
+
try {
|
|
44
|
+
pid = Number(readFileSync(lock, "utf8").trim()) || null;
|
|
45
|
+
// The lock is written once, when the daemon starts (acquireLock).
|
|
46
|
+
startedAt = statSync(lock).mtime.toISOString();
|
|
47
|
+
} catch {
|
|
48
|
+
/* no lock — no daemon */
|
|
49
|
+
}
|
|
50
|
+
const running = pid != null && isAlive(pid);
|
|
51
|
+
return { running, pid, version, startedAt: running ? startedAt : null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Pre-0.5.0 inflight files held bare turn ids. */
|
|
55
|
+
function normaliseInflight(raw) {
|
|
56
|
+
return (Array.isArray(raw) ? raw : []).map((e) => (typeof e === "string" ? { turnId: e } : e)).filter((e) => e && typeof e.turnId === "string");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `{ daemon: { running, pid, version, startedAt }, sessions: [{ sessionId,
|
|
61
|
+
* title, sessionUrl, repos, turns: [...], services: [...] }] }`.
|
|
62
|
+
* Turn state: running | exited (runner pid gone, daemon still up) |
|
|
63
|
+
* interrupted (daemon down). Service state: running | dead.
|
|
64
|
+
*/
|
|
65
|
+
export function collectProcessList({ kaiHome = KAI_HOME, isAlive = pidAlive, version = installedVersionOrNull() } = {}) {
|
|
66
|
+
const daemon = readDaemon(kaiHome, isAlive, version);
|
|
67
|
+
const inflight = normaliseInflight(readJson(join(kaiHome, "state", "inflight.json"), []));
|
|
68
|
+
const previewPids = readJson(join(kaiHome, "state", "preview-pids.json"), []);
|
|
69
|
+
const services = (Array.isArray(previewPids) ? previewPids : []).filter((e) => e && Number.isInteger(e.pid));
|
|
70
|
+
|
|
71
|
+
const sessions = new Map(); // sessionId (or null) → session block, in first-seen order
|
|
72
|
+
const sessionFor = (entry) => {
|
|
73
|
+
const id = typeof entry.sessionId === "string" ? entry.sessionId : null;
|
|
74
|
+
let s = sessions.get(id);
|
|
75
|
+
if (!s) {
|
|
76
|
+
s = { sessionId: id, title: null, sessionUrl: null, repos: [], turns: [], services: [] };
|
|
77
|
+
sessions.set(id, s);
|
|
78
|
+
}
|
|
79
|
+
// Every entry denormalises the session meta; the first one that has it wins.
|
|
80
|
+
if (!s.title && typeof entry.title === "string") s.title = entry.title;
|
|
81
|
+
if (!s.sessionUrl && typeof entry.sessionUrl === "string") s.sessionUrl = entry.sessionUrl;
|
|
82
|
+
if (!s.repos.length && Array.isArray(entry.repos)) s.repos = entry.repos.filter((r) => typeof r === "string");
|
|
83
|
+
return s;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (const e of inflight) {
|
|
87
|
+
const pid = Number.isInteger(e.pid) ? e.pid : null;
|
|
88
|
+
const state = !daemon.running ? "interrupted" : pid != null && !isAlive(pid) ? "exited" : "running";
|
|
89
|
+
sessionFor(e).turns.push({ turnId: e.turnId, agent: e.agent ?? null, harness: e.harness ?? null, profileId: e.profileId ?? null, pid, startedAt: e.startedAt ?? null, state });
|
|
90
|
+
}
|
|
91
|
+
for (const e of services) {
|
|
92
|
+
sessionFor(e).services.push({ name: e.name ?? null, pid: e.pid, port: Number.isInteger(e.port) ? e.port : null, startedAt: e.startedAt ?? null, state: isAlive(e.pid) ? "running" : "dead" });
|
|
93
|
+
}
|
|
94
|
+
return { daemon, sessions: [...sessions.values()] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── text view ───────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** "2h 13m", "3m 12s", "45s" — how long since `iso`. */
|
|
100
|
+
export function formatAge(iso, now = Date.now()) {
|
|
101
|
+
const ms = iso ? now - Date.parse(iso) : NaN;
|
|
102
|
+
if (!Number.isFinite(ms) || ms < 0) return "?";
|
|
103
|
+
const s = Math.floor(ms / 1000);
|
|
104
|
+
if (s < 60) return `${s}s`;
|
|
105
|
+
const m = Math.floor(s / 60);
|
|
106
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
107
|
+
const h = Math.floor(m / 60);
|
|
108
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
109
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Minutes-and-up version for long-lived dev servers ("14m", "2h 13m"). */
|
|
113
|
+
function formatUptime(iso, now) {
|
|
114
|
+
const age = formatAge(iso, now);
|
|
115
|
+
return age.endsWith("s") && !age.includes("m") ? age : age.replace(/ \d+s$/, "");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function turnLine(t, now) {
|
|
119
|
+
const who = [t.agent ?? "turn", t.harness && t.profileId ? `${t.harness}/${t.profileId}` : t.harness ?? ""].filter(Boolean);
|
|
120
|
+
const state =
|
|
121
|
+
t.state === "running" ? `running ${formatAge(t.startedAt, now)}` : t.state === "exited" ? "exited — runner gone, daemon still winding it down" : "interrupted — daemon is down";
|
|
122
|
+
return ` turn ${t.turnId.padEnd(26)} ${who[0].padEnd(16)} ${(who[1] ?? "").padEnd(16)} ${(t.pid != null ? `pid ${t.pid}` : "").padEnd(10)} ${state}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function serviceLine(s, now) {
|
|
126
|
+
const state = s.state === "running" ? `up ${formatUptime(s.startedAt, now)}` : "dead — stale entry";
|
|
127
|
+
return ` dev ${(s.name ?? "?").padEnd(26)} ${"".padEnd(16)} ${"".padEnd(16)} ${`pid ${s.pid}`.padEnd(10)} ${(s.port != null ? `:${s.port}` : "").padEnd(7)} ${state}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function formatProcessList(list, { now = Date.now() } = {}) {
|
|
131
|
+
const lines = [];
|
|
132
|
+
const d = list.daemon;
|
|
133
|
+
lines.push(
|
|
134
|
+
d.running
|
|
135
|
+
? `daemon: running · pid ${d.pid}${d.version ? ` · v${d.version}` : ""} · up ${formatUptime(d.startedAt, now)}`
|
|
136
|
+
: `daemon: not running${d.pid ? ` (stale lock, pid ${d.pid})` : ""} — run \`kai-bridge start\` or \`kai-bridge install\``,
|
|
137
|
+
);
|
|
138
|
+
if (!list.sessions.length) {
|
|
139
|
+
lines.push("", "nothing running on this machine");
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
for (const s of list.sessions) {
|
|
143
|
+
lines.push("");
|
|
144
|
+
const head = [`session ${s.sessionId ?? "(unknown)"}`, s.title ? `"${s.title}"` : "(untitled)"];
|
|
145
|
+
if (s.repos.length) head.push(s.repos.join(", "));
|
|
146
|
+
lines.push(head.join(" "));
|
|
147
|
+
if (s.sessionUrl) lines.push(` ${s.sessionUrl}`);
|
|
148
|
+
for (const t of s.turns) lines.push(turnLine(t, now));
|
|
149
|
+
for (const sv of s.services) lines.push(serviceLine(sv, now));
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
package/src/repository-setup.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function locateRepository(rawPath, repoKey) {
|
|
|
27
27
|
return target;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000 }, spawnGit = spawn) {
|
|
30
|
+
export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000, gitEnv = null }, spawnGit = spawn) {
|
|
31
31
|
if (typeof remote !== 'string' || !(/^(https?|ssh):\/\/\S+$/i.test(remote) || /^[\w.-]+@[\w.-]+:\S+$/.test(remote))) {
|
|
32
32
|
return Promise.reject(new Error('This repository has an unsupported clone URL.'));
|
|
33
33
|
}
|
|
@@ -53,7 +53,7 @@ export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_0
|
|
|
53
53
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
54
54
|
detached: process.platform !== 'win32',
|
|
55
55
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', SSH_ASKPASS: 'echo',
|
|
56
|
-
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' },
|
|
56
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes', ...(gitEnv || {}) },
|
|
57
57
|
});
|
|
58
58
|
let stderr = '';
|
|
59
59
|
let timedOut = false;
|
package/src/service.mjs
CHANGED
|
@@ -70,7 +70,7 @@ export function displayEnv(env = process.env) {
|
|
|
70
70
|
export function renderSystemdUnit({ program, args, env = {} }) {
|
|
71
71
|
const q = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
|
|
72
72
|
return `[Unit]
|
|
73
|
-
Description=
|
|
73
|
+
Description=Kai Code Bridge
|
|
74
74
|
Wants=network-online.target
|
|
75
75
|
After=network-online.target
|
|
76
76
|
|
package/src/setup.mjs
CHANGED
|
@@ -165,7 +165,7 @@ const rowLine = (h) => {
|
|
|
165
165
|
export async function runSetup({ binPath, prompter = makePrompter() } = {}) {
|
|
166
166
|
const config = loadConfig();
|
|
167
167
|
out("");
|
|
168
|
-
out("── Kai Bridge ────────────────────────────────────────────────");
|
|
168
|
+
out("── Kai Code Bridge ────────────────────────────────────────────────");
|
|
169
169
|
out("Run Kai Code sessions on this machine — with your own Claude /");
|
|
170
170
|
out("Codex / Cursor login, against your local checkouts.");
|
|
171
171
|
out("");
|