@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/src/preview.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { previewPorts } from './preview-ports.mjs';
|
|
1
2
|
// Preview tier A — run the app's real dev servers next to the session.
|
|
2
3
|
//
|
|
3
4
|
// Each repo may commit a `.gleap/dev.yaml`:
|
|
@@ -304,8 +305,11 @@ export function lanAddress() {
|
|
|
304
305
|
}
|
|
305
306
|
|
|
306
307
|
/** `${port:api}` → assigned port of service `api`. */
|
|
307
|
-
export function substitutePorts(value, ports) {
|
|
308
|
-
return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? ""))
|
|
308
|
+
export function substitutePorts(value, ports, urls = {}) {
|
|
309
|
+
return String(value).replace(/\$\{port:([\w-]+)\}/g, (_, name) => String(ports[name] ?? "")).replace(/\$\{url:([\w-]+)\}/g, (_, name) => {
|
|
310
|
+
if (!urls[name]) throw new Error(`Service URL is not available for ${name}.`);
|
|
311
|
+
return urls[name];
|
|
312
|
+
});
|
|
309
313
|
}
|
|
310
314
|
|
|
311
315
|
/** GET `url` accepting self-signed certificates (dev https); resolves `{ status, contentType }` or null. */
|
|
@@ -393,7 +397,8 @@ function spawnShell(cmd, { cwd, env, fd, detached = false }) {
|
|
|
393
397
|
}
|
|
394
398
|
// Windows has no process groups to kill and `detached` would open a
|
|
395
399
|
// console window — taskkill /T does the tree.
|
|
396
|
-
|
|
400
|
+
const child = spawn(cmd, { cwd, env, shell: true, stdio: ["ignore", fd, fd], detached: process.platform !== "win32" && detached, windowsHide: true });
|
|
401
|
+
return child;
|
|
397
402
|
}
|
|
398
403
|
|
|
399
404
|
/**
|
|
@@ -415,6 +420,7 @@ export class ServiceRunner {
|
|
|
415
420
|
if (settleMs === DEFAULT_SETTLE_MS) settleMs = defaultSettleMs();
|
|
416
421
|
this.kaiHome = kaiHome;
|
|
417
422
|
this.sessionId = sessionId;
|
|
423
|
+
this.serviceUrls = {};
|
|
418
424
|
this.log = log;
|
|
419
425
|
this.onStatus = onStatus;
|
|
420
426
|
this.preferredPort = typeof preferredPort === "function" ? preferredPort : null;
|
|
@@ -469,8 +475,10 @@ export class ServiceRunner {
|
|
|
469
475
|
}
|
|
470
476
|
if (this.ports[svc.name]) continue;
|
|
471
477
|
const declared = svc.port;
|
|
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(' ')}`));
|
|
472
480
|
if (declared && !(await isPortListening(declared))) {
|
|
473
|
-
this.ports[svc.name] = declared;
|
|
481
|
+
this.ports[svc.name] = await previewPorts.reserve(owner, declared, pinned, isPortListening);
|
|
474
482
|
continue;
|
|
475
483
|
}
|
|
476
484
|
if (declared) {
|
|
@@ -498,7 +506,11 @@ export class ServiceRunner {
|
|
|
498
506
|
});
|
|
499
507
|
}
|
|
500
508
|
}
|
|
501
|
-
|
|
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);
|
|
511
|
+
}
|
|
512
|
+
for (const svc of services) {
|
|
513
|
+
this.serviceUrls[svc.name] = `${svc.protocol || 'http'}://localhost:${this.ports[svc.name]}`;
|
|
502
514
|
}
|
|
503
515
|
this.registered.set(repoRoot, services);
|
|
504
516
|
this.previewNames ??= new Map();
|
|
@@ -576,7 +588,7 @@ export class ServiceRunner {
|
|
|
576
588
|
const env = {
|
|
577
589
|
...process.env,
|
|
578
590
|
PORT: String(port),
|
|
579
|
-
...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports)])),
|
|
591
|
+
...Object.fromEntries(Object.entries(svc.env).map(([k, v]) => [k, substitutePorts(v, this.ports, this.serviceUrls)])),
|
|
580
592
|
KAI_SESSION_ID: String(this.sessionId),
|
|
581
593
|
BROWSER: "none",
|
|
582
594
|
};
|
|
@@ -585,10 +597,12 @@ export class ServiceRunner {
|
|
|
585
597
|
// straight into `command not found` was the #1 preview failure. The
|
|
586
598
|
// install is authoritative: when it fails, the dev command never runs.
|
|
587
599
|
await this.ensureDeps(cwd, { env, fd, logPath, service: name, repoKey });
|
|
588
|
-
const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports));
|
|
600
|
+
const cmd = stripOpenFlag(substitutePorts(svc.run, this.ports, this.serviceUrls));
|
|
589
601
|
const child = spawnShell(cmd, { cwd, env, fd, detached: true });
|
|
590
602
|
m.ready = false;
|
|
591
603
|
child.on("exit", (code) => {
|
|
604
|
+
// An old shell can exit after a replacement has been admitted.
|
|
605
|
+
if (this.processes.get(name) !== child) return;
|
|
592
606
|
this.processes.delete(name);
|
|
593
607
|
this.onProcess(name, child.pid, "remove");
|
|
594
608
|
this.log("info", "service.exit", { name, code });
|
|
@@ -804,7 +818,11 @@ export class ServiceRunner {
|
|
|
804
818
|
const child = this.processes.get(name);
|
|
805
819
|
if (!child) return;
|
|
806
820
|
this.stopping.add(name);
|
|
807
|
-
const
|
|
821
|
+
const port = this.ports[name];
|
|
822
|
+
let timer;
|
|
823
|
+
const gone = child.exitCode !== null || child.signalCode !== null
|
|
824
|
+
? Promise.resolve()
|
|
825
|
+
: new Promise((r) => child.once("exit", r));
|
|
808
826
|
if (process.platform === "win32") {
|
|
809
827
|
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
|
|
810
828
|
} else {
|
|
@@ -819,17 +837,29 @@ export class ServiceRunner {
|
|
|
819
837
|
}
|
|
820
838
|
}
|
|
821
839
|
this.log("info", "service.stop", { name });
|
|
822
|
-
await Promise.race([gone, new Promise((r) => setTimeout(r, timeoutMs))]);
|
|
823
|
-
|
|
840
|
+
await Promise.race([gone, new Promise((r) => { timer = setTimeout(r, timeoutMs); })]);
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
// On Linux the shell may exit before its children close the listener.
|
|
843
|
+
// Starting the replacement now can mistake that old listener for readiness
|
|
844
|
+
// and then crash with EADDRINUSE. Wait for the owned service's port too.
|
|
845
|
+
const deadline = Date.now() + timeoutMs;
|
|
846
|
+
while (port && await isPortListening(port) && Date.now() < deadline) {
|
|
847
|
+
await new Promise(r => setTimeout(r, 50));
|
|
848
|
+
}
|
|
849
|
+
if (this.processes.get(name) === child || (port && await isPortListening(port))) {
|
|
824
850
|
try {
|
|
825
851
|
process.platform === "win32" ? child.kill() : process.kill(-child.pid, "SIGKILL");
|
|
826
852
|
} catch {
|
|
827
853
|
/* gone */
|
|
828
854
|
}
|
|
829
855
|
}
|
|
856
|
+
const forceDeadline = Date.now() + 1000;
|
|
857
|
+
while (port && await isPortListening(port) && Date.now() < forceDeadline) await new Promise(r => setTimeout(r, 50));
|
|
858
|
+
if (port && await isPortListening(port)) throw new PreviewError(`${name} did not release port ${port}; retry after checking the service.`, { code: 'port_busy', service: name });
|
|
830
859
|
}
|
|
831
860
|
|
|
832
861
|
stopAll() {
|
|
862
|
+
previewPorts.releaseSession(this.sessionId);
|
|
833
863
|
for (const [name, child] of this.processes) {
|
|
834
864
|
this.stopping.add(name);
|
|
835
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.
|
|
@@ -138,12 +139,15 @@ export function openLoginTerminal(harness, configDir) {
|
|
|
138
139
|
: harness === "codex"
|
|
139
140
|
? `CODEX_HOME=${shellQuote(configDir)} `
|
|
140
141
|
: "NO_OPEN_BROWSER=0 ";
|
|
141
|
-
const line = `${envPrefix}${
|
|
142
|
+
const line = `${harness === 'claude' && process.platform !== 'win32' ? 'DISABLE_AUTOUPDATER=1 ' : ''}${envPrefix}${[c.cmd, ...c.args].map(shellQuote).join(' ')}`;
|
|
142
143
|
if (process.platform === "darwin") {
|
|
143
144
|
return spawn("osascript", ["-e", `tell application "Terminal" to activate`, "-e", `tell application "Terminal" to do script ${JSON.stringify(line)}`], { stdio: "ignore", detached: true });
|
|
144
145
|
}
|
|
145
146
|
if (process.platform === "win32") {
|
|
146
|
-
|
|
147
|
+
// cmd uses double quotes, and profile variables belong in the child env;
|
|
148
|
+
// POSIX KEY='value' prefixes are not valid Windows commands.
|
|
149
|
+
const windowsLine = [c.cmd, ...c.args].map(value => `"${String(value).replace(/"/g, '""')}"`).join(' ');
|
|
150
|
+
return spawn("cmd", ["/c", "start", '""', "cmd", "/k", windowsLine], { env: { ...c.env, ...(harness === 'claude' ? { DISABLE_AUTOUPDATER: '1' } : {}) }, stdio: "ignore", detached: true, shell: true });
|
|
147
151
|
}
|
|
148
152
|
for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) {
|
|
149
153
|
if (findBinary(term)) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, dirname, isAbsolute } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { inspectCheckout, normalizeRemote } from './repos.mjs';
|
|
6
|
+
|
|
7
|
+
const locks = new Map();
|
|
8
|
+
export function withRepositoryLock(target, action) {
|
|
9
|
+
const key = resolve(target);
|
|
10
|
+
const prior = locks.get(key) || Promise.resolve();
|
|
11
|
+
const work = prior.catch(() => {}).then(action);
|
|
12
|
+
const tail = work.catch(() => {});
|
|
13
|
+
locks.set(key, tail);
|
|
14
|
+
void tail.then(() => { if (locks.get(key) === tail) locks.delete(key); });
|
|
15
|
+
return work;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function locateRepository(rawPath, repoKey) {
|
|
19
|
+
const expanded = String(rawPath || '').trim().replace(/^~(?=$|\/)/, homedir());
|
|
20
|
+
if (!isAbsolute(expanded)) throw new Error('Enter an absolute folder path on this device.');
|
|
21
|
+
const target = resolve(expanded);
|
|
22
|
+
if (!existsSync(target)) throw new Error(`${target} does not exist on this machine.`);
|
|
23
|
+
// Validate BEFORE recording an override or adding a scan root.
|
|
24
|
+
if (repoKey && inspectCheckout(target)?.key !== repoKey) {
|
|
25
|
+
throw new Error(`${target} is not a checkout of ${repoKey}. Choose the folder for that repository.`);
|
|
26
|
+
}
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function cloneRepository({ remote, name, root, repoKey, timeoutMs = 180_000 }, spawnGit = spawn) {
|
|
31
|
+
if (typeof remote !== 'string' || !(/^(https?|ssh):\/\/\S+$/i.test(remote) || /^[\w.-]+@[\w.-]+:\S+$/.test(remote))) {
|
|
32
|
+
return Promise.reject(new Error('This repository has an unsupported clone URL.'));
|
|
33
|
+
}
|
|
34
|
+
if (!root || !existsSync(root)) return Promise.reject(new Error('No workspace folder is available. Add a scan folder in device settings.'));
|
|
35
|
+
if (typeof name !== 'string' || !name || name === '.' || name === '..' || name.startsWith('-') || /[/\\\x00-\x1f]/.test(name)) {
|
|
36
|
+
return Promise.reject(new Error('Invalid repository folder name.'));
|
|
37
|
+
}
|
|
38
|
+
const target = resolve(root, name);
|
|
39
|
+
if (dirname(target) !== resolve(root)) return Promise.reject(new Error('Invalid repository folder name.'));
|
|
40
|
+
const expectedKey = repoKey || normalizeRemote(remote)?.key;
|
|
41
|
+
if (!expectedKey || normalizeRemote(remote)?.key !== expectedKey) return Promise.reject(new Error('The clone URL belongs to a different repository.'));
|
|
42
|
+
return withRepositoryLock(target, async () => {
|
|
43
|
+
if (existsSync(target)) {
|
|
44
|
+
// Another session may have just cloned the same repo. Reuse only an
|
|
45
|
+
// exact identity match; never touch or remove an existing folder.
|
|
46
|
+
if (inspectCheckout(target)?.key === expectedKey) return target;
|
|
47
|
+
throw Object.assign(new Error(`${target} already exists. Use Locate existing to choose the correct checkout.`), { target });
|
|
48
|
+
}
|
|
49
|
+
mkdirSync(target); // exclusive reservation; cleanup only what we created
|
|
50
|
+
try {
|
|
51
|
+
await new Promise((accept, reject) => {
|
|
52
|
+
const child = spawnGit('git', ['clone', '--', remote, target], {
|
|
53
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
54
|
+
detached: process.platform !== 'win32',
|
|
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' },
|
|
57
|
+
});
|
|
58
|
+
let stderr = '';
|
|
59
|
+
let timedOut = false;
|
|
60
|
+
let killTimer;
|
|
61
|
+
const signalGit = (signal) => {
|
|
62
|
+
try {
|
|
63
|
+
// SSH/helpers must exit too before the directory can be cleaned.
|
|
64
|
+
if (child.pid && process.platform !== 'win32') process.kill(-child.pid, signal);
|
|
65
|
+
else child.kill(signal);
|
|
66
|
+
} catch { /* already exited */ }
|
|
67
|
+
};
|
|
68
|
+
child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4000); });
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
timedOut = true;
|
|
71
|
+
signalGit('SIGTERM');
|
|
72
|
+
killTimer = setTimeout(() => signalGit('SIGKILL'), 2000);
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
const finish = (err) => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
clearTimeout(killTimer);
|
|
77
|
+
if (err) reject(err); else accept();
|
|
78
|
+
};
|
|
79
|
+
child.on('error', finish);
|
|
80
|
+
// Wait for exit before deleting the partial clone/releasing the lock.
|
|
81
|
+
child.on('close', (code) => {
|
|
82
|
+
const detail = stderr.trim().split('\n').filter(Boolean).slice(-2).join(' · ')
|
|
83
|
+
.replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1');
|
|
84
|
+
finish(timedOut ? new Error('Cloning timed out. Check the network and try again.') : code === 0 ? null
|
|
85
|
+
: new Error(`Could not clone the repository${detail ? `: ${detail}` : '.'}`));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
if (inspectCheckout(target)?.key !== expectedKey) throw new Error('The cloned repository does not match the requested repository.');
|
|
89
|
+
return target;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
rmSync(target, { recursive: true, force: true });
|
|
92
|
+
err.target = target;
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
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("");
|
package/src/workspace.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
|
|
|
81
81
|
// Resume: the worktree from the previous turn is the session state.
|
|
82
82
|
return { cwd: dir, mode, branch, base, resumed: true };
|
|
83
83
|
}
|
|
84
|
-
mkdirSync(
|
|
84
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
85
85
|
git(repo.primaryPath, ["fetch", "origin", base, "--quiet"]);
|
|
86
86
|
git(repo.primaryPath, ["worktree", "add", "-b", branch, dir, `origin/${base}`]);
|
|
87
87
|
// A fresh worktree has no node_modules; clone the primary checkout's
|
|
@@ -170,16 +170,9 @@ export function discardChanges(cwd) {
|
|
|
170
170
|
export function removeWorktree({ kaiHome, repo, sessionId, title }) {
|
|
171
171
|
const dir = worktreePath(kaiHome, repo.name, sessionSlug(sessionId, title));
|
|
172
172
|
if (!existsSync(dir)) return false;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
rmSync(dir, { recursive: true, force: true });
|
|
177
|
-
try {
|
|
178
|
-
git(repo.primaryPath, ["worktree", "prune"]);
|
|
179
|
-
} catch {
|
|
180
|
-
/* best-effort */
|
|
181
|
-
}
|
|
182
|
-
}
|
|
173
|
+
// Never destroy dirty or unpushed work during session cleanup.
|
|
174
|
+
if (collectChanges(dir).files.length || git(dir, ['log', 'HEAD', '--not', '--remotes', '--oneline'])) return false;
|
|
175
|
+
git(repo.primaryPath, ['worktree', 'remove', dir]);
|
|
183
176
|
return true;
|
|
184
177
|
}
|
|
185
178
|
|