@gleapai/kai-bridge 0.7.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 +2 -2
- package/package.json +3 -7
- package/scripts/runtime-smoke.mjs +1 -1
- package/src/daemon.mjs +37 -69
- package/src/executor.mjs +3 -16
- 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 +12 -18
- package/src/profiles.mjs +6 -5
- package/src/service.mjs +1 -1
- package/src/setup.mjs +1 -1
- package/src/workspace.mjs +4 -23
- 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
package/README.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Kai Code Bridge
|
|
2
2
|
|
|
3
|
-
Run [Gleap Kai Code](https://gleap.io)
|
|
4
|
-
Claude Code / Codex login
|
|
5
|
-
Gleap app on your phone.
|
|
3
|
+
Run [Gleap Kai Code](https://gleap.io) on your computer or server with your own
|
|
4
|
+
Claude Code / Codex login, local dev-server previews, and photo or video verification.
|
|
6
5
|
|
|
7
6
|
```bash
|
|
8
7
|
npm i -g @gleapai/kai-bridge # one install, so the background service has a stable path
|
|
@@ -43,3 +42,28 @@ kai-bridge run --task "Explain the auth flow in this repo" --cwd ~/code/my-app
|
|
|
43
42
|
|
|
44
43
|
`npm run sync-runner` copies the ACP runner from a sibling `gleap_code_analyzer` checkout;
|
|
45
44
|
`npm test` runs the unit suite (uses real `git`).
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Computers and your own servers
|
|
48
|
+
|
|
49
|
+
Kai Code offers **Kai Code Cloud** (Gleap's managed execution, using AI credits) or **Kai Code Bridge** (your coding agents on your computer or a server you manage, using your own AI subscriptions).
|
|
50
|
+
|
|
51
|
+
Run `npm i -g @gleapai/kai-bridge && kai-bridge` in a local terminal or over SSH. The guided setup pairs the device, installs its background service and helps sign in to coding agents. Node.js 20+ and Git are required. On a Linux server, keep the user service running after logout using your system's service configuration; `kai-bridge doctor` reports service status.
|
|
52
|
+
|
|
53
|
+
Codex and Claude Code ship with Kai Code Bridge. Missing agents can be installed or repaired, and existing agents can be updated independently:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
kai-bridge harness install codex
|
|
57
|
+
kai-bridge harness update codex
|
|
58
|
+
kai-bridge harness login codex --device-auth
|
|
59
|
+
|
|
60
|
+
kai-bridge harness install claude
|
|
61
|
+
kai-bridge harness update claude
|
|
62
|
+
kai-bridge harness login claude
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Updates install into `~/.kai/harnesses`, verify the new executable, then select it for subsequent sessions. A failed download or startup leaves the previous version selected. Existing versions are retained for running sessions; your global CLI installations and agent logins are not overwritten. `--version <version>` selects a specific package version (for Claude, the Claude Agent SDK package version).
|
|
66
|
+
|
|
67
|
+
Codex automatically uses device-code sign-in over SSH or on headless Linux. Enable device-code sign-in in the account settings if Codex asks. Claude uses its native `auth login` flow: follow the URL and prompts in the SSH terminal. Dashboard **Sign in** opens a terminal on desktop devices; for headless servers it shows the exact command to run over SSH. The login is picked up automatically.
|
|
68
|
+
|
|
69
|
+
**Previews are local to the Kai Code Bridge device.** Open them on that computer, on the same network when supported, or use your own SSH port forwarding. Kai Code Bridge does not expose previews through a public tunnel. Screenshot and video verification runs on the Kai Code Bridge device and uploads its evidence to the Kai Code session, so those checks still work on a remote server.
|
package/bin/kai-bridge.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// kai-bridge install | uninstall run at login (launchd / systemd / Task Scheduler)
|
|
8
8
|
// kai-bridge start run in the foreground (what the service runs)
|
|
9
9
|
// kai-bridge status device, service, profiles, repos
|
|
10
|
-
// kai-bridge harness list|install <id>|login <id> Claude Code + Codex are bundled; Cursor is downloaded
|
|
10
|
+
// kai-bridge harness list|install <id>|update <id>|login <id> [--device-auth] Claude Code + Codex are bundled; Cursor is downloaded
|
|
11
11
|
// kai-bridge profile list|add <id> --harness claude|codex|cursor [--label …]|login <id>|remove <id>
|
|
12
12
|
// kai-bridge repo scan|roots [add <dir>|remove <dir>]|primary <repoKey> <path>
|
|
13
13
|
// kai-bridge run --task "…" [--model …] [--profile …] [--cwd …] (local turn, no server)
|
|
@@ -89,7 +89,8 @@ async function profile() {
|
|
|
89
89
|
if (sub === "login") {
|
|
90
90
|
const p = resolveProfiles(config).find((x) => x.id === positional[1]);
|
|
91
91
|
if (!p) fail("unknown profile");
|
|
92
|
-
|
|
92
|
+
await installHarness(p.harness, { kaiHome: KAI_HOME, onLog: out });
|
|
93
|
+
const c = loginCommand(p.harness, p.configDir, KAI_HOME, flags["device-auth"] ? { deviceAuth: true } : undefined);
|
|
93
94
|
if (!c) fail(`${p.harness} is not installed`);
|
|
94
95
|
const child = spawn(c.cmd, c.args, { env: c.env, stdio: "inherit" });
|
|
95
96
|
child.on("close", (code) => process.exit(code ?? 0));
|
|
@@ -111,9 +112,9 @@ async function harness() {
|
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
114
|
const id = positional[1];
|
|
114
|
-
if (!HARNESS_INFO[id]) fail("usage: harness list|install <
|
|
115
|
+
if (!HARNESS_INFO[id]) fail("usage: harness list|install <id>|update <id>|login <id> (claude|codex|cursor)");
|
|
115
116
|
if (sub === "install" || sub === "update") {
|
|
116
|
-
const res = await installHarness(id, { kaiHome: KAI_HOME, onLog: out });
|
|
117
|
+
const res = await installHarness(id, { kaiHome: KAI_HOME, onLog: out, update: sub === "update", version: typeof flags.version === "string" ? flags.version : undefined });
|
|
117
118
|
if (!res.ok) fail(`${id} is not usable`);
|
|
118
119
|
out(`${HARNESS_INFO[id].label} ${res.version} → ${res.binary}`);
|
|
119
120
|
return;
|
|
@@ -123,7 +124,7 @@ async function harness() {
|
|
|
123
124
|
positional[0] = "login";
|
|
124
125
|
return profile();
|
|
125
126
|
}
|
|
126
|
-
fail("usage: harness list|install <id>|login <id>");
|
|
127
|
+
fail("usage: harness list|install <id>|update <id>|login <id> [--device-auth]");
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
async function repo() {
|
|
@@ -280,7 +281,7 @@ try {
|
|
|
280
281
|
|
|
281
282
|
setup guided onboarding (pair · service · sign-ins)
|
|
282
283
|
login | logout | install | uninstall | start | status | doctor | update
|
|
283
|
-
harness list|install <id>|login <id>
|
|
284
|
+
harness list|install <id>|update <id>|login <id> [--device-auth]
|
|
284
285
|
profile list|add <id> --harness claude|codex|cursor|login <id>|remove <id>
|
|
285
286
|
repo scan|roots [add|remove <dir>]|primary <repoKey> <path>
|
|
286
287
|
run --task "…" [--model …] [--profile …] [--cwd …]`);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@gleapai/kai-bridge",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.9.0",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
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": {
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"runner",
|
|
14
14
|
"scripts/postinstall.mjs",
|
|
15
15
|
"README.md",
|
|
16
|
-
"fly",
|
|
17
16
|
"scripts/runtime-smoke.mjs",
|
|
18
17
|
"npm-shrinkwrap.json"
|
|
19
18
|
],
|
|
@@ -52,8 +51,5 @@
|
|
|
52
51
|
"type": "git",
|
|
53
52
|
"url": "git+https://github.com/GleapSDK/kai-bridge.git"
|
|
54
53
|
},
|
|
55
|
-
"homepage": "https://gleap.io"
|
|
56
|
-
"kaiHostedRuntime": {
|
|
57
|
-
"protocol": 1
|
|
58
|
-
}
|
|
54
|
+
"homepage": "https://gleap.io"
|
|
59
55
|
}
|
|
@@ -55,6 +55,6 @@ try {
|
|
|
55
55
|
await initialize(adapter.cmd, adapter.args, { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 'kai-runtime-check', version: '1.0.0' } });
|
|
56
56
|
}
|
|
57
57
|
// Imports catch packaging omissions and syntax/dependency failures before boot.
|
|
58
|
-
await import('../src/daemon.mjs'); await import('../src/
|
|
58
|
+
await import('../src/daemon.mjs'); await import('../src/harness-install.mjs');
|
|
59
59
|
console.log('Native CLIs, Codex app-server and both ACP adapters passed isolated startup checks.');
|
|
60
60
|
} finally { rmSync(home, { recursive: true, force: true }); }
|
package/src/daemon.mjs
CHANGED
|
@@ -28,9 +28,6 @@ import { BridgeApi, createEventBatcher } from "./api.mjs";
|
|
|
28
28
|
import { ensureClaudeAcpPatched } from "./acp-patch.mjs";
|
|
29
29
|
import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
30
30
|
import { runTurn } from "./executor.mjs";
|
|
31
|
-
import { awaitHostedAdmission, hostedResources, syncHostedRepositories } from './hosted.mjs';
|
|
32
|
-
import { HostedTunnel } from './hosted-tunnel.mjs';
|
|
33
|
-
import { hostedLoginExpired, markHostedLoginExpired, hostedLoginRecovery } from './hosted-auth.mjs';
|
|
34
31
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
35
32
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
36
33
|
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
@@ -188,14 +185,6 @@ export class BridgeDaemon {
|
|
|
188
185
|
|
|
189
186
|
async hello() {
|
|
190
187
|
const profiles = await describeProfiles(resolveProfiles(this.config, this.kaiHome), this.usageByProfile);
|
|
191
|
-
if (this.config.hosted) {
|
|
192
|
-
for (const profile of resolveProfiles(this.config, this.kaiHome)) {
|
|
193
|
-
if (hostedLoginExpired(profile, this.kaiHome)) {
|
|
194
|
-
const report = profiles.find(p => p.id === profile.id);
|
|
195
|
-
if (report) report.authState = 'signed_out';
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
188
|
const repos = toDeviceRepoReport(this.repoGroups);
|
|
200
189
|
return this.api.hello({
|
|
201
190
|
name: this.config.device?.name,
|
|
@@ -272,16 +261,9 @@ export class BridgeDaemon {
|
|
|
272
261
|
// machine stayed offline until the next login — silently.
|
|
273
262
|
this.heartbeat = setInterval(() => {
|
|
274
263
|
this.api
|
|
275
|
-
.heartbeat({ running: [...this.running.keys()]
|
|
264
|
+
.heartbeat({ running: [...this.running.keys()] })
|
|
276
265
|
.catch((err) => this.onApiError("heartbeat", err));
|
|
277
266
|
}, HEARTBEAT_MS);
|
|
278
|
-
if (this.config.hosted) {
|
|
279
|
-
this.hostedTunnel = new HostedTunnel({ config: this.config, api: this.api, log: this.log });
|
|
280
|
-
this.hostedTunnel.connect();
|
|
281
|
-
void this.hostedTunnel.register('maintenance', 'machine-browser', 6080).catch(err => this.log('warn', 'hosted.browser', { error: err.message }));
|
|
282
|
-
const sync = () => void syncHostedRepositories(this).catch(err => this.log('warn', 'hosted.repositories', { error: err.message }));
|
|
283
|
-
sync(); this.hostedRepoTimer = setInterval(sync, 30_000);
|
|
284
|
-
}
|
|
285
267
|
// Plan-usage windows for the composer popover. Fire-and-forget on a
|
|
286
268
|
// slow cadence, unref'd (the heartbeat keeps the process alive), and
|
|
287
269
|
// NEVER in hello's path — the probe spawns the CLI (~2s per profile).
|
|
@@ -409,8 +391,6 @@ export class BridgeDaemon {
|
|
|
409
391
|
let changed = false;
|
|
410
392
|
const profiles = resolveProfiles(this.config, this.kaiHome);
|
|
411
393
|
for (const harness of MODEL_PROBE_HARNESSES) {
|
|
412
|
-
// Model probing must not start a second Codex authentication owner.
|
|
413
|
-
if (this.config.hosted && harness === 'codex') continue;
|
|
414
394
|
// The user's own ~/.claude / ~/.codex first, then any signed-in
|
|
415
395
|
// managed profile — the catalogue is per account, not per profile.
|
|
416
396
|
const candidates = profiles
|
|
@@ -480,8 +460,6 @@ export class BridgeDaemon {
|
|
|
480
460
|
clearInterval(this.usageTimer);
|
|
481
461
|
clearInterval(this.modelsTimer);
|
|
482
462
|
clearInterval(this.updateTimer);
|
|
483
|
-
clearInterval(this.hostedRepoTimer);
|
|
484
|
-
this.hostedTunnel?.close();
|
|
485
463
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
486
464
|
for (const entry of this.running.values()) entry.ctrl.abort();
|
|
487
465
|
const reports = [];
|
|
@@ -805,8 +783,6 @@ export class BridgeDaemon {
|
|
|
805
783
|
}
|
|
806
784
|
return this.startTurn(data);
|
|
807
785
|
case "bridge.turn.cancel":
|
|
808
|
-
this.hostedCancelled ??= new Set();
|
|
809
|
-
this.hostedCancelled.add(data.turnId);
|
|
810
786
|
this.running.get(data.turnId)?.ctrl.abort();
|
|
811
787
|
return;
|
|
812
788
|
case "bridge.turn.steer":
|
|
@@ -881,7 +857,6 @@ export class BridgeDaemon {
|
|
|
881
857
|
* how non-github companions get cloned; `note` survives onto error writes.
|
|
882
858
|
*/
|
|
883
859
|
async previewStart({ sessionId, title, repos, companionRemotes = null }) {
|
|
884
|
-
if (this.config.hosted) { this.hostedPreviewWork ??= new Set(); this.hostedPreviewWork.add(sessionId); }
|
|
885
860
|
let lastNote = null;
|
|
886
861
|
const skipped = [];
|
|
887
862
|
// Every report is also RETURNED: the verify turn boots the preview
|
|
@@ -889,11 +864,7 @@ export class BridgeDaemon {
|
|
|
889
864
|
const report = async (payload) => {
|
|
890
865
|
if (payload.note) lastNote = payload.note;
|
|
891
866
|
const body = payload.status === "error" ? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) } : payload;
|
|
892
|
-
|
|
893
|
-
const runner = this.services.get(sessionId);
|
|
894
|
-
const publicPreview = p => this.config.hosted && runner?.publicUrls?.[p.name] ? { ...p, url: runner.publicUrls[p.name], lanUrl: null } : p;
|
|
895
|
-
const reportBody = this.config.hosted ? { ...body, previews: body.previews?.map(publicPreview), urls: body.urls?.map(publicPreview), landingUrl: body.previews?.[0] ? publicPreview(body.previews[0]).url : body.landingUrl } : body;
|
|
896
|
-
await this.api.sessionPreview(sessionId, reportBody).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
867
|
+
await this.api.sessionPreview(sessionId, body).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
897
868
|
return body;
|
|
898
869
|
};
|
|
899
870
|
const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
|
|
@@ -908,7 +879,7 @@ export class BridgeDaemon {
|
|
|
908
879
|
return fail(new PreviewError(`Repository ${r.key} is not checked out on this device.`, { code: "companion_missing", repo: r.key }), { repo: r.key });
|
|
909
880
|
}
|
|
910
881
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
911
|
-
const cwd = mode === "local" ? group.primary.path : this.findSessionWorktree(
|
|
882
|
+
const cwd = mode === "local" ? group.primary.path : this.findSessionWorktree(group.name, sessionId, title);
|
|
912
883
|
if (!cwd || !existsSync(cwd)) {
|
|
913
884
|
return fail(new PreviewError(`The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`, { code: "other", repo: r.key }), { repo: r.key });
|
|
914
885
|
}
|
|
@@ -1073,7 +1044,6 @@ export class BridgeDaemon {
|
|
|
1073
1044
|
findSessionWorktree(repoName, sessionId, title) {
|
|
1074
1045
|
const exact = worktreePath(this.kaiHome, repoName, sessionSlug(sessionId, title));
|
|
1075
1046
|
if (existsSync(exact)) return exact;
|
|
1076
|
-
if (this.config.hosted) return exact;
|
|
1077
1047
|
const suffix = `-${String(sessionId || "").slice(-8)}`;
|
|
1078
1048
|
if (suffix.length < 2) return exact;
|
|
1079
1049
|
try {
|
|
@@ -1162,7 +1132,7 @@ export class BridgeDaemon {
|
|
|
1162
1132
|
this.previewIdleTimers.delete(sessionId);
|
|
1163
1133
|
const runner = this.services.get(sessionId);
|
|
1164
1134
|
if (!runner) return;
|
|
1165
|
-
if (
|
|
1135
|
+
if (await this.previewHasConnections(runner)) {
|
|
1166
1136
|
this.log("info", "preview.idle.connected", { sessionId });
|
|
1167
1137
|
if (this.services.get(sessionId) === runner) this.armPreviewIdleTimer(sessionId);
|
|
1168
1138
|
return;
|
|
@@ -1195,7 +1165,6 @@ export class BridgeDaemon {
|
|
|
1195
1165
|
}
|
|
1196
1166
|
|
|
1197
1167
|
async previewStop({ sessionId }) {
|
|
1198
|
-
this.hostedTunnel?.unregisterSession(sessionId);
|
|
1199
1168
|
this.manualPreviews?.delete(sessionId);
|
|
1200
1169
|
this.verifyOwnedPreviews?.delete(sessionId);
|
|
1201
1170
|
// A sign-in window for this session has nothing left to sign in to.
|
|
@@ -1213,7 +1182,6 @@ export class BridgeDaemon {
|
|
|
1213
1182
|
* dashboard why — with the classified log line.
|
|
1214
1183
|
*/
|
|
1215
1184
|
onServiceDied(sessionId, info) {
|
|
1216
|
-
this.hostedTunnel?.unregisterSession(sessionId);
|
|
1217
1185
|
const runner = this.services.get(sessionId);
|
|
1218
1186
|
if (!runner) return Promise.resolve();
|
|
1219
1187
|
this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
|
|
@@ -1243,7 +1211,6 @@ export class BridgeDaemon {
|
|
|
1243
1211
|
runner = new ServiceRunner({
|
|
1244
1212
|
kaiHome: this.kaiHome,
|
|
1245
1213
|
sessionId,
|
|
1246
|
-
...(this.hostedTunnel ? { registerPublicService: (name, port, protocol) => this.hostedTunnel.register(sessionId, name, port, protocol) } : {}),
|
|
1247
1214
|
log: this.log,
|
|
1248
1215
|
onStatus: (message) => {
|
|
1249
1216
|
this.log("info", "preview.status", { sessionId, message });
|
|
@@ -1269,7 +1236,7 @@ export class BridgeDaemon {
|
|
|
1269
1236
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
1270
1237
|
const ws = materializeBinding({
|
|
1271
1238
|
kaiHome: this.kaiHome,
|
|
1272
|
-
repo: { name:
|
|
1239
|
+
repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
|
|
1273
1240
|
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
|
|
1274
1241
|
sessionId: turn.sessionId,
|
|
1275
1242
|
title: turn.title,
|
|
@@ -1303,7 +1270,7 @@ export class BridgeDaemon {
|
|
|
1303
1270
|
);
|
|
1304
1271
|
for (const g of others.slice(0, 40)) {
|
|
1305
1272
|
const base = g.primary.defaultBranch || "main";
|
|
1306
|
-
lines.push(`- ${g.key}: git -C ${g.primary.path} fetch origin ${base} --quiet && git -C ${g.primary.path} worktree add -b kai/${slug} ${worktreePath(this.kaiHome,
|
|
1273
|
+
lines.push(`- ${g.key}: git -C ${g.primary.path} fetch origin ${base} --quiet && git -C ${g.primary.path} worktree add -b kai/${slug} ${worktreePath(this.kaiHome, g.name, slug)} origin/${base}`);
|
|
1307
1274
|
}
|
|
1308
1275
|
if (others.length > 40) lines.push(`- (${others.length - 40} more repositories on this device — same recipe, path ~/.kai/worktrees/<repo>/${slug})`);
|
|
1309
1276
|
}
|
|
@@ -1320,7 +1287,7 @@ export class BridgeDaemon {
|
|
|
1320
1287
|
const adopted = [];
|
|
1321
1288
|
for (const g of this.repoGroups) {
|
|
1322
1289
|
if (bound.some((b) => b.key === g.key)) continue;
|
|
1323
|
-
const cwd = worktreePath(this.kaiHome,
|
|
1290
|
+
const cwd = worktreePath(this.kaiHome, g.name, slug);
|
|
1324
1291
|
if (!existsSync(cwd)) continue;
|
|
1325
1292
|
const branch = currentBranch(cwd);
|
|
1326
1293
|
if (!branch || branch === "HEAD") continue;
|
|
@@ -1333,8 +1300,6 @@ export class BridgeDaemon {
|
|
|
1333
1300
|
async startTurn(turn) {
|
|
1334
1301
|
const { turnId } = turn;
|
|
1335
1302
|
if (this.running.has(turnId)) return;
|
|
1336
|
-
if (this.config.hosted && !(await awaitHostedAdmission(this, turn))) return;
|
|
1337
|
-
if (this.running.has(turnId)) return;
|
|
1338
1303
|
const ctrl = new AbortController();
|
|
1339
1304
|
const entry = { ctrl, control: null, sessionId: turn.sessionId };
|
|
1340
1305
|
this.running.set(turnId, entry);
|
|
@@ -1349,8 +1314,6 @@ export class BridgeDaemon {
|
|
|
1349
1314
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
1350
1315
|
try {
|
|
1351
1316
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
1352
|
-
if (this.config.hosted && (profile.kind === 'gleap-key' || !['codex', 'claude'].includes(profile.harness))) throw new Error('Choose your signed-in Claude Code or Codex profile for this machine.');
|
|
1353
|
-
if (this.config.hosted && hostedLoginExpired(profile, this.kaiHome)) throw new Error(hostedLoginRecovery(profile.harness));
|
|
1354
1317
|
const bound = this.bindRepos(turn);
|
|
1355
1318
|
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
1356
1319
|
// reachable as siblings under the same worktree root or by their
|
|
@@ -1434,11 +1397,6 @@ export class BridgeDaemon {
|
|
|
1434
1397
|
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
1435
1398
|
});
|
|
1436
1399
|
await batcher.flush();
|
|
1437
|
-
if (this.config.hosted && res.errorCode === 'authentication_required') {
|
|
1438
|
-
markHostedLoginExpired(profile, this.kaiHome);
|
|
1439
|
-
res.lastError = hostedLoginRecovery(profile.harness);
|
|
1440
|
-
await this.hello().catch(() => {});
|
|
1441
|
-
}
|
|
1442
1400
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
1443
1401
|
// Plan and verify turns are read-only: worktrees are restored, local
|
|
1444
1402
|
// checkouts only reported, nothing is ever committed or pushed.
|
|
@@ -2501,10 +2459,10 @@ export class BridgeDaemon {
|
|
|
2501
2459
|
}
|
|
2502
2460
|
}
|
|
2503
2461
|
|
|
2504
|
-
async harnessInstall({ commandId, harness }) {
|
|
2462
|
+
async harnessInstall({ commandId, harness, update = false }) {
|
|
2505
2463
|
const lines = [];
|
|
2506
2464
|
try {
|
|
2507
|
-
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 }); } });
|
|
2508
2466
|
await this.hello();
|
|
2509
2467
|
if (commandId) await this.api.commandAck(commandId, { ok: res.ok, version: res.version, log: lines });
|
|
2510
2468
|
} catch (err) {
|
|
@@ -2514,24 +2472,34 @@ export class BridgeDaemon {
|
|
|
2514
2472
|
}
|
|
2515
2473
|
|
|
2516
2474
|
async profileLogin({ commandId, profileId }) {
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
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
|
+
}
|
|
2535
2503
|
}
|
|
2536
2504
|
}
|
|
2537
2505
|
|
package/src/executor.mjs
CHANGED
|
@@ -15,8 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
import { ambientConfigDir, managedConfigDir } from "./profiles.mjs";
|
|
16
16
|
import { harnessAcpCommand, harnessBinary } from "./harnesses.mjs";
|
|
17
17
|
import { KAI_HOME } from "./config.mjs";
|
|
18
|
-
import {
|
|
19
|
-
import { classifyHarnessFailure } from './hosted-auth.mjs';
|
|
18
|
+
import { classifyHarnessFailure } from './harness-errors.mjs';
|
|
20
19
|
|
|
21
20
|
const RUNNER = join(dirname(fileURLToPath(import.meta.url)), "..", "runner", "acp-runner.mjs");
|
|
22
21
|
const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v), "utf8").toString("base64");
|
|
@@ -79,13 +78,10 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
|
|
|
79
78
|
for (const [k, v] of Object.entries(extraEnv || {})) if (v != null && v !== "") env[k] = String(v);
|
|
80
79
|
// Never leak the Gleap device token into the harness.
|
|
81
80
|
delete env.KAI_DEVICE_TOKEN;
|
|
82
|
-
delete env.KAI_BOOTSTRAP_TOKEN;
|
|
83
81
|
// Login, version probes and coding must resolve the same release's binaries.
|
|
84
|
-
// Hosted Codex replaces this path below with its single-owner RPC transport.
|
|
85
82
|
const native = harnessBinary(profile.harness, kaiHome);
|
|
86
83
|
if (native && profile.harness === 'claude') { env.CLAUDE_CODE_EXECUTABLE = native; env.DISABLE_AUTOUPDATER = '1'; }
|
|
87
84
|
if (native && profile.harness === 'codex') env.CODEX_PATH = native;
|
|
88
|
-
if (process.env.KAI_HOSTED === '1') env.NODE_OPTIONS = '--max-old-space-size=1536';
|
|
89
85
|
// The daemon's own node first on PATH for the runner, the harness and
|
|
90
86
|
// every shell the agent opens. Under launchd PATH is frozen at install
|
|
91
87
|
// time and a stale /usr/local/bin/node (v20 here) beat the nvm node the
|
|
@@ -107,12 +103,6 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
|
|
|
107
103
|
env.KAI_ACP_CONFIG_DIR = join(kaiHome, "state", profile.id, "cursor");
|
|
108
104
|
mkdirSync(env.KAI_ACP_CONFIG_DIR, { recursive: true });
|
|
109
105
|
} else if (profile.harness === "codex") {
|
|
110
|
-
if (process.env.KAI_HOSTED === '1') {
|
|
111
|
-
// A single supervised app-server owns login refresh for every hosted
|
|
112
|
-
// turn. Per-session config travels over RPC, never through config.toml.
|
|
113
|
-
env.KAI_ACP_CONFIG_DIR = profileDir;
|
|
114
|
-
env.CODEX_PATH = join(dirname(fileURLToPath(import.meta.url)), 'codex-broker-client.mjs');
|
|
115
|
-
} else {
|
|
116
106
|
// The runner writes config.toml (MCP servers, kill-switches) into
|
|
117
107
|
// CODEX_HOME — that must never be the user's real ~/.codex. Use a
|
|
118
108
|
// per-profile session dir seeded with a COPY of auth.json (read-only
|
|
@@ -122,7 +112,6 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
|
|
|
122
112
|
const src = join(profileDir, "auth.json");
|
|
123
113
|
if (existsSync(src)) copyFileSync(src, join(sessionDir, "auth.json"));
|
|
124
114
|
env.KAI_ACP_CONFIG_DIR = sessionDir;
|
|
125
|
-
}
|
|
126
115
|
} else {
|
|
127
116
|
// Claude: CLAUDE_CONFIG_DIR IS the login dir; the runner reads the
|
|
128
117
|
// transcript from it and passes settings via the SDK, never by file.
|
|
@@ -159,8 +148,7 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
|
|
|
159
148
|
// stdin is the runner's control channel (JSONL: steer / cancel) — see
|
|
160
149
|
// startControlChannel in runner/acp-runner.mjs. Never closed from
|
|
161
150
|
// here; the runner exits on its own when the turn ends.
|
|
162
|
-
const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env,
|
|
163
|
-
boundHostedProcess(child, { onExceeded: message => { lastError = message; onEvent({ type: 'error', message, code: 'memory_pressure' }); } });
|
|
151
|
+
const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
164
152
|
child.stdin.on("error", () => {});
|
|
165
153
|
onSpawn?.({
|
|
166
154
|
/** Write one control line; false when the runner is already gone. */
|
|
@@ -199,8 +187,7 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
|
|
|
199
187
|
child.stderr.on("data", (d) => onLog(String(d)));
|
|
200
188
|
const onAbort = () => {
|
|
201
189
|
try {
|
|
202
|
-
|
|
203
|
-
else child.kill("SIGTERM");
|
|
190
|
+
child.kill("SIGTERM");
|
|
204
191
|
} catch {
|
|
205
192
|
/* gone */
|
|
206
193
|
}
|
|
@@ -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
|
+
}
|